SyncModels.swift 809 lignes · 31525 octets
import Foundation

/// Shared JSON config: snake_case wire format (Projectiaon server), unknown fields ignored.
/// Mirror of Kotlin `syncJson` (`JsonNamingStrategy.SnakeCase`, `ignoreUnknownKeys`, `encodeDefaults`).
enum SyncJson {
    static func encoder() -> JSONEncoder {
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        return encoder
    }

    static func decoder() -> JSONDecoder {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }

    static func encodeToString<T: Encodable>(_ value: T) -> String {
        guard let data = try? encoder().encode(value) else { return "{}" }
        return String(data: data, encoding: .utf8) ?? "{}"
    }

    static func decode<T: Decodable>(_ type: T.Type, from string: String) throws -> T {
        try decoder().decode(type, from: Data(string.utf8))
    }
}

// ---- Auth clé ----

struct AuthCleRequest: Codable, Equatable {
    var nom: String
    var motDePasse: String
}

struct AuthCleResponse: Codable, Equatable {
    var nom: String
    var cleChiffree: String
    var sel: String
    var kdf: String
    var cipher: String
}

// ---- Sync status / pull ----

struct SyncChanges: Codable, Equatable {
    var contacts: Int = 0
    var entreprises: Int = 0
    var projets: Int = 0
    var taches: Int = 0
    var interactions: Int = 0
    var rdv: Int = 0
    var reservations: Int = 0
    var indisponibilites: Int = 0
    var tombstones: Int = 0
}

extension SyncChanges {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        contacts = try c.decodeIfPresent(Int.self, forKey: .contacts) ?? 0
        entreprises = try c.decodeIfPresent(Int.self, forKey: .entreprises) ?? 0
        projets = try c.decodeIfPresent(Int.self, forKey: .projets) ?? 0
        taches = try c.decodeIfPresent(Int.self, forKey: .taches) ?? 0
        interactions = try c.decodeIfPresent(Int.self, forKey: .interactions) ?? 0
        rdv = try c.decodeIfPresent(Int.self, forKey: .rdv) ?? 0
        reservations = try c.decodeIfPresent(Int.self, forKey: .reservations) ?? 0
        indisponibilites = try c.decodeIfPresent(Int.self, forKey: .indisponibilites) ?? 0
        tombstones = try c.decodeIfPresent(Int.self, forKey: .tombstones) ?? 0
    }
}

struct SyncStatusResponse: Codable, Equatable {
    var serverTime: String
    var changes: SyncChanges
    var total: Int
}

struct SyncPullResponse: Codable, Equatable {
    var serverTime: String
    var contacts: [ContactDto] = []
    var entreprises: [EntrepriseDto] = []
    var projets: [ProjetDto] = []
    var taches: [TacheSyncDto] = []
    var interactions: [InteractionDto] = []
    var rdv: [RendezVousDto] = []
    var reservations: [ReservationDto] = []
    var indisponibilites: [IndisponibiliteDto] = []
    var tombstones: [TombstoneDto] = []
    var workflows: [WorkflowDto] = []
    var notes: [NoteProjetDto] = []
}

extension SyncPullResponse {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        serverTime = try c.decode(String.self, forKey: .serverTime)
        contacts = try c.decodeIfPresent([ContactDto].self, forKey: .contacts) ?? []
        entreprises = try c.decodeIfPresent([EntrepriseDto].self, forKey: .entreprises) ?? []
        projets = try c.decodeIfPresent([ProjetDto].self, forKey: .projets) ?? []
        taches = try c.decodeIfPresent([TacheSyncDto].self, forKey: .taches) ?? []
        interactions = try c.decodeIfPresent([InteractionDto].self, forKey: .interactions) ?? []
        rdv = try c.decodeIfPresent([RendezVousDto].self, forKey: .rdv) ?? []
        reservations = try c.decodeIfPresent([ReservationDto].self, forKey: .reservations) ?? []
        indisponibilites = try c.decodeIfPresent([IndisponibiliteDto].self, forKey: .indisponibilites) ?? []
        tombstones = try c.decodeIfPresent([TombstoneDto].self, forKey: .tombstones) ?? []
        workflows = try c.decodeIfPresent([WorkflowDto].self, forKey: .workflows) ?? []
        notes = try c.decodeIfPresent([NoteProjetDto].self, forKey: .notes) ?? []
    }
}

struct TombstoneDto: Codable, Equatable {
    var entityType: String
    var id: String
    var projetId: String? = nil
    var cibleType: String? = nil
    var cibleId: String? = nil
    var utilisateur: String? = nil
    var supprimeLe: String

    enum CodingKeys: String, CodingKey {
        // Kotlin `@SerialName("type")`.
        case entityType = "type"
        case id
        case projetId
        case cibleType
        case cibleId
        case utilisateur
        case supprimeLe
    }
}

extension TombstoneDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        entityType = try c.decode(String.self, forKey: .entityType)
        id = try c.decode(String.self, forKey: .id)
        projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
        cibleType = try c.decodeIfPresent(String.self, forKey: .cibleType)
        cibleId = try c.decodeIfPresent(String.self, forKey: .cibleId)
        utilisateur = try c.decodeIfPresent(String.self, forKey: .utilisateur)
        supprimeLe = try c.decode(String.self, forKey: .supprimeLe)
    }
}

// ---- Entités sync (champs minimaux pour LWW / merge) ----

struct ContactDto: Codable, Equatable {
    var id: String
    var prenom: String = ""
    var nom: String = ""
    var entrepriseId: String? = nil
    var fonction: String = ""
    var statut: String = "prospect"
    var etape: String = "nouveau"
    var tags: [String] = []
    var creePar: String = ""
    var creeLe: String
    var misAJourLe: String? = nil
    /// Server-side file name (`carte.jpg`), not the bytes.
    var carteVisite: String? = nil
    /// Server-side file name (`photo.jpg`), not the bytes.
    var photo: String? = nil
}

extension ContactDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        prenom = try c.decodeIfPresent(String.self, forKey: .prenom) ?? ""
        nom = try c.decodeIfPresent(String.self, forKey: .nom) ?? ""
        entrepriseId = try c.decodeIfPresent(String.self, forKey: .entrepriseId)
        fonction = try c.decodeIfPresent(String.self, forKey: .fonction) ?? ""
        statut = try c.decodeIfPresent(String.self, forKey: .statut) ?? "prospect"
        etape = try c.decodeIfPresent(String.self, forKey: .etape) ?? "nouveau"
        tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
        creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
        carteVisite = try c.decodeIfPresent(String.self, forKey: .carteVisite)
        photo = try c.decodeIfPresent(String.self, forKey: .photo)
    }
}

struct EntrepriseDto: Codable, Equatable {
    var id: String
    var nom: String
    var secteur: String = ""
    var creePar: String = ""
    var creeLe: String
    var misAJourLe: String? = nil
}

extension EntrepriseDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        nom = try c.decode(String.self, forKey: .nom)
        secteur = try c.decodeIfPresent(String.self, forKey: .secteur) ?? ""
        creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
    }
}

struct MembreProjetDto: Codable, Equatable {
    var utilisateur: String
    var niveau: String = "lecteur"
}

extension MembreProjetDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        utilisateur = try c.decode(String.self, forKey: .utilisateur)
        niveau = try c.decodeIfPresent(String.self, forKey: .niveau) ?? "lecteur"
    }
}

struct ProjetDto: Codable, Equatable {
    var id: String
    var nom: String
    var description: String = ""
    var workflowId: String
    var creePar: String = ""
    var creeLe: String
    var membres: [MembreProjetDto] = []
    /// Le projet utilise-t-il la planification ? Défaut faux = serveur antérieur.
    var planification: Bool = false
    var echeance: String? = nil
    /// 5 = lun-ven, 6 = lun-sam, 7 = tous les jours.
    var joursOuvres: Int = 7
    var misAJourLe: String? = nil
}

extension ProjetDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        nom = try c.decode(String.self, forKey: .nom)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        workflowId = try c.decode(String.self, forKey: .workflowId)
        creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        membres = try c.decodeIfPresent([MembreProjetDto].self, forKey: .membres) ?? []
        planification = try c.decodeIfPresent(Bool.self, forKey: .planification) ?? false
        echeance = try c.decodeIfPresent(String.self, forKey: .echeance)
        joursOuvres = try c.decodeIfPresent(Int.self, forKey: .joursOuvres) ?? 7
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
    }
}

/// Élément de checklist d'une tâche (miroir Rust `SousTache`).
struct SousTacheDto: Codable, Equatable {
    var id: String
    var titre: String
    var fait: Bool = false
}

extension SousTacheDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        titre = try c.decode(String.self, forKey: .titre)
        fait = try c.decodeIfPresent(Bool.self, forKey: .fait) ?? false
    }
}

/// Flattened task: `projet_id` + Tache fields at the same level (mirror of Rust `TacheSync`).
struct TacheSyncDto: Codable, Equatable {
    var projetId: String
    var id: String
    var titre: String
    var description: String = ""
    var colonneId: String
    var ordre: Int = 0
    var auteur: String = ""
    var creeLe: String
    var assigneA: String? = nil
    /// Premier jour planifié, `AAAA-MM-JJ`.
    var debut: String? = nil
    /// Durée en jours **ouvrés** selon le rythme du projet.
    var dureeJours: Int? = nil
    var etiquette: String? = nil
    /// Transportés et stockés pour ne pas les perdre à l'écriture, non éditables.
    var dependDe: [String] = []
    var parentId: String? = nil
    var sousTaches: [SousTacheDto] = []
    var misAJourLe: String? = nil
}

extension TacheSyncDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        projetId = try c.decode(String.self, forKey: .projetId)
        id = try c.decode(String.self, forKey: .id)
        titre = try c.decode(String.self, forKey: .titre)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        colonneId = try c.decode(String.self, forKey: .colonneId)
        ordre = try c.decodeIfPresent(Int.self, forKey: .ordre) ?? 0
        auteur = try c.decodeIfPresent(String.self, forKey: .auteur) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        assigneA = try c.decodeIfPresent(String.self, forKey: .assigneA)
        debut = try c.decodeIfPresent(String.self, forKey: .debut)
        dureeJours = try c.decodeIfPresent(Int.self, forKey: .dureeJours)
        etiquette = try c.decodeIfPresent(String.self, forKey: .etiquette)
        dependDe = try c.decodeIfPresent([String].self, forKey: .dependDe) ?? []
        parentId = try c.decodeIfPresent(String.self, forKey: .parentId)
        sousTaches = try c.decodeIfPresent([SousTacheDto].self, forKey: .sousTaches) ?? []
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
    }
}

struct NoteProjetDto: Codable, Equatable {
    var id: String
    var projetId: String
    var titre: String
    var contenu: String
    var auteur: String = ""
    var creeLe: String
    var majLe: String? = nil
    var audio: String? = nil
    var transcription: String? = nil
    var transcriptionErreur: String? = nil
}

extension NoteProjetDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        projetId = try c.decode(String.self, forKey: .projetId)
        titre = try c.decode(String.self, forKey: .titre)
        contenu = try c.decode(String.self, forKey: .contenu)
        auteur = try c.decodeIfPresent(String.self, forKey: .auteur) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        majLe = try c.decodeIfPresent(String.self, forKey: .majLe)
        audio = try c.decodeIfPresent(String.self, forKey: .audio)
        transcription = try c.decodeIfPresent(String.self, forKey: .transcription)
        transcriptionErreur = try c.decodeIfPresent(String.self, forKey: .transcriptionErreur)
    }
}

struct InteractionDto: Codable, Equatable {
    var id: String
    var contactId: String
    var typeInteraction: String = "note"
    var sujet: String = ""
    var description: String = ""
    var creePar: String = ""
    var creeLe: String
    var misAJourLe: String? = nil
    /// Nom de fichier côté serveur (pas un chemin local).
    var pieceJointe: String? = nil
    /// Statut de transcription : `en_attente`, `terminee`, `echec`.
    var transcription: String? = nil
    var transcriptionErreur: String? = nil
}

extension InteractionDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        contactId = try c.decode(String.self, forKey: .contactId)
        typeInteraction = try c.decodeIfPresent(String.self, forKey: .typeInteraction) ?? "note"
        sujet = try c.decodeIfPresent(String.self, forKey: .sujet) ?? ""
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
        pieceJointe = try c.decodeIfPresent(String.self, forKey: .pieceJointe)
        transcription = try c.decodeIfPresent(String.self, forKey: .transcription)
        transcriptionErreur = try c.decodeIfPresent(String.self, forKey: .transcriptionErreur)
    }
}

struct ColonneDto: Codable, Equatable {
    var id: String
    var nom: String
    var ordre: Int = 0
}

extension ColonneDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        nom = try c.decode(String.self, forKey: .nom)
        ordre = try c.decodeIfPresent(Int.self, forKey: .ordre) ?? 0
    }
}

/// Reservation/unavailability target (mirror of Rust enum `CibleRessource`, tag `type` + `id`).
struct CibleRessourceDto: Codable, Equatable {
    var type: String
    var id: String
}

struct RendezVousDto: Codable, Equatable {
    var id: String
    var titre: String = ""
    var description: String = ""
    var utilisateur: String = ""
    var contactIds: [String] = []
    var lieu: String = ""
    var debut: String
    var fin: String
    var projetId: String? = nil
    var creePar: String = ""
    var creeLe: String
    var misAJourLe: String? = nil
}

extension RendezVousDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        titre = try c.decodeIfPresent(String.self, forKey: .titre) ?? ""
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        utilisateur = try c.decodeIfPresent(String.self, forKey: .utilisateur) ?? ""
        contactIds = try c.decodeIfPresent([String].self, forKey: .contactIds) ?? []
        lieu = try c.decodeIfPresent(String.self, forKey: .lieu) ?? ""
        debut = try c.decode(String.self, forKey: .debut)
        fin = try c.decode(String.self, forKey: .fin)
        projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
        creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
    }
}

struct ReservationDto: Codable, Equatable {
    var id: String
    var cible: CibleRessourceDto
    var debut: String
    var fin: String
    var motif: String = ""
    var projetId: String? = nil
    var rdvId: String? = nil
    var statut: String = "active"
    var reservePar: String = ""
    var creeLe: String
    var misAJourLe: String? = nil
}

extension ReservationDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        cible = try c.decode(CibleRessourceDto.self, forKey: .cible)
        debut = try c.decode(String.self, forKey: .debut)
        fin = try c.decode(String.self, forKey: .fin)
        motif = try c.decodeIfPresent(String.self, forKey: .motif) ?? ""
        projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
        rdvId = try c.decodeIfPresent(String.self, forKey: .rdvId)
        statut = try c.decodeIfPresent(String.self, forKey: .statut) ?? "active"
        reservePar = try c.decodeIfPresent(String.self, forKey: .reservePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
    }
}

struct IndisponibiliteDto: Codable, Equatable {
    var id: String
    var cible: CibleRessourceDto
    var nature: String
    var debut: String
    var fin: String
    var commentaire: String = ""
    var creePar: String = ""
    var creeLe: String
    var misAJourLe: String? = nil
}

extension IndisponibiliteDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        cible = try c.decode(CibleRessourceDto.self, forKey: .cible)
        nature = try c.decode(String.self, forKey: .nature)
        debut = try c.decode(String.self, forKey: .debut)
        fin = try c.decode(String.self, forKey: .fin)
        commentaire = try c.decodeIfPresent(String.self, forKey: .commentaire) ?? ""
        creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
        misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
    }
}

/// Lightweight catalogue resource (salle/matériel/véhicule): shared fields only.
struct RessourceItemDto: Codable, Equatable {
    var id: String
    var nom: String
    var description: String = ""
    var lieu: String = ""
    var actif: Bool = true
}

extension RessourceItemDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        nom = try c.decode(String.self, forKey: .nom)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        lieu = try c.decodeIfPresent(String.self, forKey: .lieu) ?? ""
        actif = try c.decodeIfPresent(Bool.self, forKey: .actif) ?? true
    }
}

struct RessourcesCatalogueDto: Codable, Equatable {
    var salles: [RessourceItemDto] = []
    var materiels: [RessourceItemDto] = []
    var vehicules: [RessourceItemDto] = []
}

extension RessourcesCatalogueDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        salles = try c.decodeIfPresent([RessourceItemDto].self, forKey: .salles) ?? []
        materiels = try c.decodeIfPresent([RessourceItemDto].self, forKey: .materiels) ?? []
        vehicules = try c.decodeIfPresent([RessourceItemDto].self, forKey: .vehicules) ?? []
    }
}

struct WorkflowDto: Codable, Equatable {
    var id: String
    var nom: String
    var description: String = ""
    var colonnes: [ColonneDto] = []
    var creePar: String = ""
    var creeLe: String
}

extension WorkflowDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        nom = try c.decode(String.self, forKey: .nom)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        colonnes = try c.decodeIfPresent([ColonneDto].self, forKey: .colonnes) ?? []
        creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
        creeLe = try c.decode(String.self, forKey: .creeLe)
    }
}

// ---- Local mutation requests (SyncOp payloads, mirror of server `*Input`) ----
// Kotlin (`encodeDefaults` + `explicitNulls`) encodes nil optionals as JSON null;
// the custom `encode(to:)` below reproduces that.

struct CreateTacheRequest: Codable, Equatable {
    var titre: String
    var description: String = ""
    var colonneId: String = ""
    var assigneA: String? = nil

    enum CodingKeys: String, CodingKey {
        case titre, description, colonneId, assigneA
    }
}

extension CreateTacheRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        titre = try c.decode(String.self, forKey: .titre)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        colonneId = try c.decodeIfPresent(String.self, forKey: .colonneId) ?? ""
        assigneA = try c.decodeIfPresent(String.self, forKey: .assigneA)
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(titre, forKey: .titre)
        try c.encode(description, forKey: .description)
        try c.encode(colonneId, forKey: .colonneId)
        try encodeExplicitNull(assigneA, in: &c, forKey: .assigneA)
    }
}

struct UpdateTacheRequest: Codable, Equatable {
    var titre: String
    var description: String = ""
    var assigneA: String? = nil
    /// Kotlin `@SerialName("assigne_a_pose")` — matches the snake_case strategy output.
    var assigneAPose: Bool = true

    enum CodingKeys: String, CodingKey {
        case titre, description, assigneA, assigneAPose
    }
}

extension UpdateTacheRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        titre = try c.decode(String.self, forKey: .titre)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        assigneA = try c.decodeIfPresent(String.self, forKey: .assigneA)
        assigneAPose = try c.decodeIfPresent(Bool.self, forKey: .assigneAPose) ?? true
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(titre, forKey: .titre)
        try c.encode(description, forKey: .description)
        try encodeExplicitNull(assigneA, in: &c, forKey: .assigneA)
        try c.encode(assigneAPose, forKey: .assigneAPose)
    }
}

struct MoveTacheRequest: Codable, Equatable {
    var colonneId: String
}

struct CreateInteractionRequest: Codable, Equatable {
    var sujet: String
    var description: String = ""
    var typeInteraction: String = "note"
    var demandeTranscription: Bool = false
}

extension CreateInteractionRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        sujet = try c.decode(String.self, forKey: .sujet)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        typeInteraction = try c.decodeIfPresent(String.self, forKey: .typeInteraction) ?? "note"
        demandeTranscription = try c.decodeIfPresent(Bool.self, forKey: .demandeTranscription) ?? false
    }
}

/// Create note de projet payload (body of `POST /api/projets/:id/notes`).
struct CreateNoteProjetRequest: Codable, Equatable {
    var titre: String
    var contenu: String = ""
    var demandeTranscription: Bool = false
}

extension CreateNoteProjetRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        titre = try c.decode(String.self, forKey: .titre)
        contenu = try c.decodeIfPresent(String.self, forKey: .contenu) ?? ""
        demandeTranscription = try c.decodeIfPresent(Bool.self, forKey: .demandeTranscription) ?? false
    }
}

struct ContactValeurDto: Codable, Equatable {
    var valeur: String
    var categorie: String = "pro"
}

extension ContactValeurDto {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        valeur = try c.decode(String.self, forKey: .valeur)
        categorie = try c.decodeIfPresent(String.self, forKey: .categorie) ?? "pro"
    }
}

/// Create/update contact payload (mirror of server `ContactInput`).
struct CreateContactRequest: Codable, Equatable {
    var prenom: String = ""
    var nom: String = ""
    var entrepriseId: String? = nil
    var entrepriseNom: String? = nil
    var fonction: String = ""
    var emails: [ContactValeurDto] = []
    var telephones: [ContactValeurDto] = []
    var notes: String = ""
    var statut: String = "prospect"
    var etape: String = "nouveau"
    var tags: [String] = []

    enum CodingKeys: String, CodingKey {
        case prenom, nom, entrepriseId, entrepriseNom, fonction, emails, telephones, notes, statut, etape, tags
    }
}

extension CreateContactRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        prenom = try c.decodeIfPresent(String.self, forKey: .prenom) ?? ""
        nom = try c.decodeIfPresent(String.self, forKey: .nom) ?? ""
        entrepriseId = try c.decodeIfPresent(String.self, forKey: .entrepriseId)
        entrepriseNom = try c.decodeIfPresent(String.self, forKey: .entrepriseNom)
        fonction = try c.decodeIfPresent(String.self, forKey: .fonction) ?? ""
        emails = try c.decodeIfPresent([ContactValeurDto].self, forKey: .emails) ?? []
        telephones = try c.decodeIfPresent([ContactValeurDto].self, forKey: .telephones) ?? []
        notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
        statut = try c.decodeIfPresent(String.self, forKey: .statut) ?? "prospect"
        etape = try c.decodeIfPresent(String.self, forKey: .etape) ?? "nouveau"
        tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(prenom, forKey: .prenom)
        try c.encode(nom, forKey: .nom)
        try encodeExplicitNull(entrepriseId, in: &c, forKey: .entrepriseId)
        try encodeExplicitNull(entrepriseNom, in: &c, forKey: .entrepriseNom)
        try c.encode(fonction, forKey: .fonction)
        try c.encode(emails, forKey: .emails)
        try c.encode(telephones, forKey: .telephones)
        try c.encode(notes, forKey: .notes)
        try c.encode(statut, forKey: .statut)
        try c.encode(etape, forKey: .etape)
        try c.encode(tags, forKey: .tags)
    }
}

/// Create projet payload (mirror server `ProjetInput`, body of `/api/projets`).
struct CreateProjetRequest: Codable, Equatable {
    var nom: String
    var description: String = ""
    var workflowId: String

    enum CodingKeys: String, CodingKey {
        case nom, description, workflowId
    }
}

extension CreateProjetRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        nom = try c.decode(String.self, forKey: .nom)
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        workflowId = try c.decode(String.self, forKey: .workflowId)
    }
}

/// Create/update RDV payload (mirror server, body of `/api/rdv`).
struct RdvUpsertRequest: Codable, Equatable {
    var titre: String = ""
    var description: String = ""
    var lieu: String = ""
    var debut: String
    var fin: String
    var contactIds: [String] = []
    var projetId: String? = nil

    enum CodingKeys: String, CodingKey {
        case titre, description, lieu, debut, fin, contactIds, projetId
    }
}

extension RdvUpsertRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        titre = try c.decodeIfPresent(String.self, forKey: .titre) ?? ""
        description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
        lieu = try c.decodeIfPresent(String.self, forKey: .lieu) ?? ""
        debut = try c.decode(String.self, forKey: .debut)
        fin = try c.decode(String.self, forKey: .fin)
        contactIds = try c.decodeIfPresent([String].self, forKey: .contactIds) ?? []
        projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(titre, forKey: .titre)
        try c.encode(description, forKey: .description)
        try c.encode(lieu, forKey: .lieu)
        try c.encode(debut, forKey: .debut)
        try c.encode(fin, forKey: .fin)
        try c.encode(contactIds, forKey: .contactIds)
        try encodeExplicitNull(projetId, in: &c, forKey: .projetId)
    }
}

/// Create/update reservation payload (mirror server, body of `/api/ressources/:genre/:id/reservations`).
struct ReservationUpsertRequest: Codable, Equatable {
    var debut: String
    var fin: String
    var motif: String = ""
    var projetId: String? = nil
    var rdvId: String? = nil

    enum CodingKeys: String, CodingKey {
        case debut, fin, motif, projetId, rdvId
    }
}

extension ReservationUpsertRequest {
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        debut = try c.decode(String.self, forKey: .debut)
        fin = try c.decode(String.self, forKey: .fin)
        motif = try c.decodeIfPresent(String.self, forKey: .motif) ?? ""
        projetId = try c.decodeIfPresent(String.self, forKey: .projetId)
        rdvId = try c.decodeIfPresent(String.self, forKey: .rdvId)
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(debut, forKey: .debut)
        try c.encode(fin, forKey: .fin)
        try c.encode(motif, forKey: .motif)
        try encodeExplicitNull(projetId, in: &c, forKey: .projetId)
        try encodeExplicitNull(rdvId, in: &c, forKey: .rdvId)
    }
}

/// Encodes `nil` as an explicit JSON null (Kotlin `explicitNulls` default).
private func encodeExplicitNull<K: CodingKey, T: Encodable>(
    _ value: T?,
    in container: inout KeyedEncodingContainer<K>,
    forKey key: K
) throws {
    if let value {
        try container.encode(value, forKey: key)
    } else {
        try container.encodeNil(forKey: key)
    }
}