clickup/
folders.rs

1use crate::client::ClickUpClient;
2use crate::error::{ClickUpError, Result};
3use chrono::{Datelike, Utc};
4use serde::Deserialize;
5use serde_json::Value;
6/// Smart Folder Finder: Busca inteligente de folder_id usando API do ClickUp
7///
8/// ESTRATÉGIA ROBUSTA E GENÉRICA:
9/// 1. **API Search**: GET folders do ClickUp + fuzzy matching semântico por nome
10/// 2. **Historical Fallback**: DESCONTINUADO (sem dependência de campos customizados)
11/// 3. **Generic Field Support**: Funciona com qualquer campo customizado como string
12///
13/// PRINCIPAIS FUNCIONALIDADES:
14/// - `find_folder_for_client()`: Busca usando nome do cliente (original)
15/// - `find_folder_by_extracted_value()`: Busca usando valor pré-extraído (NOVO)
16/// - `find_folder_from_task_field()`: Busca extraindo campo de tarefa (NOVO)
17/// - `extract_custom_field_value()`: Helper para extrair valores de campos (NOVO)
18///
19/// LÓGICA DE BUSCA POR NOME (sem dependência de campos customizados):
20/// - Recebe string do cliente (extraída de info_2 ou qualquer outro campo)
21/// - Compara APENAS pelo nome da pasta, usando similaridade de strings
22/// - NÃO utiliza campos customizados das tarefas para determinação de estrutura
23///
24/// RETORNA:
25/// - folder_id: ID da pasta encontrada
26/// - list_id: ID da lista do mês atual (ou cria se não existir)
27/// - confidence: Nível de confiança da busca (1.0 = exact, 0.70+ = fuzzy)
28/// - search_method: Método usado (ExactMatch, FuzzyMatch, SemanticMatch)
29use std::collections::HashMap;
30
31// REMOVED: CLIENT_SOLICITANTE_FIELD_ID constant
32// Reason: Campo "Cliente Solicitante" foi descontinuado do sistema
33const FUZZY_THRESHOLD: f64 = 0.70; // Reduzido de 0.85 para 0.70
34
35/// Deserializa ID que pode vir como string ou integer da API do ClickUp
36fn deserialize_id_flexible<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
37where
38    D: serde::Deserializer<'de>,
39{
40    use serde::de::{self, Deserialize};
41
42    let value = Value::deserialize(deserializer)?;
43    match value {
44        Value::String(s) => Ok(s),
45        Value::Number(n) => Ok(n.to_string()),
46        _ => Err(de::Error::custom("id must be string or number")),
47    }
48}
49#[allow(dead_code)]
50const MIN_HISTORICAL_CONFIDENCE: f64 = 0.5;
51
52#[derive(Debug, Clone)]
53pub struct FolderSearchResult {
54    pub folder_id: String,
55    pub folder_name: String,
56    pub list_id: Option<String>,
57    pub list_name: Option<String>,
58    pub confidence: f64,
59    pub search_method: SearchMethod,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum SearchMethod {
64    ExactMatch,      // Nome exato encontrado
65    FuzzyMatch,      // Similaridade >= 0.85
66    SemanticMatch,   // Busca semântica (embeddings)
67    HistoricalMatch, // Encontrado em tarefas anteriores
68    NotFound,        // Não encontrado (usar fallback)
69}
70
71/// Estrutura de resposta da API do ClickUp para folders
72#[derive(Debug, Deserialize)]
73struct ClickUpFoldersResponse {
74    folders: Vec<ClickUpFolder>,
75}
76
77#[derive(Debug, Deserialize, Clone)]
78struct ClickUpFolder {
79    #[serde(deserialize_with = "deserialize_id_flexible")]
80    id: String,
81    name: String,
82    #[allow(dead_code)]
83    lists: Option<Vec<ClickUpList>>,
84}
85
86#[derive(Debug, Deserialize, Clone)]
87struct ClickUpList {
88    #[serde(deserialize_with = "deserialize_id_flexible")]
89    #[allow(dead_code)]
90    id: String,
91    #[allow(dead_code)]
92    name: String,
93}
94
95/// Estrutura de resposta da API do ClickUp para tasks
96#[derive(Debug, Deserialize)]
97struct ClickUpTasksResponse {
98    tasks: Vec<ClickUpTask>,
99}
100
101#[derive(Debug, Deserialize)]
102struct ClickUpTask {
103    #[serde(deserialize_with = "deserialize_id_flexible")]
104    #[allow(dead_code)]
105    id: String,
106    #[allow(dead_code)]
107    name: Option<String>,
108    #[allow(dead_code)]
109    folder: Option<ClickUpTaskFolder>,
110    #[allow(dead_code)]
111    list: Option<ClickUpTaskList>,
112    #[allow(dead_code)]
113    custom_fields: Option<Vec<ClickUpCustomField>>,
114}
115
116#[derive(Debug, Deserialize)]
117struct ClickUpTaskFolder {
118    #[serde(deserialize_with = "deserialize_id_flexible")]
119    #[allow(dead_code)]
120    id: String,
121    #[allow(dead_code)]
122    name: String,
123}
124
125#[derive(Debug, Deserialize)]
126struct ClickUpTaskList {
127    #[allow(dead_code)]
128    #[serde(deserialize_with = "deserialize_id_flexible")]
129    id: String,
130    #[allow(dead_code)]
131    name: String,
132}
133
134#[derive(Debug, Deserialize)]
135pub struct ClickUpCustomField {
136    #[allow(dead_code)]
137    id: String,
138    #[allow(dead_code)]
139    value: Option<serde_json::Value>,
140}
141
142#[derive(Debug)]
143pub struct SmartFolderFinder {
144    client: ClickUpClient,
145    workspace_id: String,
146    cache: HashMap<String, FolderSearchResult>,
147}
148
149impl SmartFolderFinder {
150    /// Criar novo finder
151    pub fn new(client: ClickUpClient, workspace_id: String) -> Self {
152        Self {
153            client,
154            workspace_id,
155            cache: HashMap::new(),
156        }
157    }
158
159    /// Criar novo finder a partir de API token (conveniência)
160    pub fn from_token(api_token: String, workspace_id: String) -> Result<Self> {
161        let client = ClickUpClient::new(api_token)?;
162        Ok(Self::new(client, workspace_id))
163    }
164
165    /// Busca inteligente de folder por nome do cliente
166    ///
167    /// LÓGICA DE BUSCA POR NOME (sem dependência de campos customizados):
168    /// - Recebe o nome do cliente (extraído de qualquer campo como info_2) pelo worker/core
169    /// - Compara APENAS pelo nome da pasta, usando similaridade de string
170    /// - NÃO utiliza campos customizados das tarefas para determinação de estrutura
171    ///
172    /// Fases:
173    /// 1. Cache lookup (se já buscou antes)
174    /// 2. API search com fuzzy matching por nome de pasta
175    /// 3. Historical search DESCONTINUADO (campo personalizado removido)
176    /// 4. Fallback (retorna None)
177    pub async fn find_folder_for_client(
178        &mut self,
179        client_name: &str,
180    ) -> Result<Option<FolderSearchResult>> {
181        let normalized_name = Self::normalize_name(client_name);
182
183        tracing::info!(
184            "🔍 SmartFolderFinder: Buscando folder para '{}'",
185            client_name
186        );
187
188        // 1. Cache lookup
189        if let Some(cached) = self.cache.get(&normalized_name) {
190            tracing::info!("✅ Encontrado em cache: folder_id={}", cached.folder_id);
191            return Ok(Some(cached.clone()));
192        }
193
194        // 2. API Search (folders)
195        match self.search_folders_via_api(&normalized_name).await {
196            Ok(Some(result)) => {
197                self.cache.insert(normalized_name.clone(), result.clone());
198                return Ok(Some(result));
199            }
200            Ok(None) => {
201                tracing::info!("⚠️ Não encontrado via API, tentando busca histórica...");
202            }
203            Err(e) => {
204                tracing::warn!(
205                    "⚠️ Erro na busca via API: {}, tentando busca histórica...",
206                    e
207                );
208            }
209        }
210
211        // 3. Historical Search (FUNCIONALIDADE DESCONTINUADA)
212        match self.search_historical_tasks(&normalized_name).await {
213            Ok(Some(result)) => {
214                self.cache.insert(normalized_name.clone(), result.clone());
215                return Ok(Some(result));
216            }
217            Ok(None) => {
218                tracing::warn!(
219                    "⚠️ Cliente '{}' não encontrado (nem API, nem histórico)",
220                    client_name
221                );
222            }
223            Err(e) => {
224                tracing::error!("❌ Erro na busca histórica: {}", e);
225            }
226        }
227
228        // 4. Fallback
229        Ok(None)
230    }
231
232    /// Nova funcionalidade: Busca inteligente de folder usando string extraída de campo customizado
233    ///
234    /// LÓGICA GENÉRICA PARA QUALQUER CAMPO:
235    /// - Recebe uma string extraída pelo worker/core de qualquer campo (info_2, outro campo, etc.)
236    /// - Esta função é independente do tipo/nome do campo original
237    /// - A extração do valor do campo deve ser feita ANTES de chamar este método
238    ///
239    /// VANTAGENS:
240    /// - Totalmente desacoplada de campos específicos
241    /// - Funciona com qualquer campo customizado do ClickUp
242    /// - Reutiliza toda a lógica de fuzzy matching existente
243    /// - Mantém compatibilidade com API de folders
244    ///
245    /// EXEMPLO DE USO:
246    /// ```no_run
247    /// # use clickup::folders::SmartFolderFinder;
248    /// # use clickup::client::ClickUpClient;
249    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
250    /// let client = ClickUpClient::new("your_api_token".to_string())?;
251    /// let mut finder = SmartFolderFinder::new(client, "workspace_id".to_string());
252    /// let client_name = "ACME Corporation";
253    /// let result = finder.find_folder_by_extracted_value(&client_name).await?;
254    /// # Ok(())
255    /// # }
256    /// ```
257    pub async fn find_folder_by_extracted_value(
258        &mut self,
259        extracted_value: &str,
260    ) -> Result<Option<FolderSearchResult>> {
261        tracing::info!(
262            "🔍 SmartFolderFinder: Buscando folder usando valor extraído: '{}'",
263            extracted_value
264        );
265
266        // Reutiliza toda a lógica existente de find_folder_for_client
267        // Isso garante consistência e evita duplicação de código
268        self.find_folder_for_client(extracted_value).await
269    }
270
271    /// Método auxiliar para extração de valor de campo customizado específico
272    ///
273    /// PROPÓSITO: Demonstra como extrair valores de campos customizados
274    /// NOTA: Este método deve ser usado pelo worker/core ANTES de chamar find_folder_by_extracted_value
275    ///
276    /// PARÂMETROS:
277    /// - custom_fields: Array de campos customizados da tarefa
278    /// - target_field_id: ID do campo a ser extraído (ex: "info_2", ou qualquer outro)
279    ///
280    /// RETORNO: Option<String> com o valor do campo, se encontrado
281    pub fn extract_custom_field_value(
282        custom_fields: &[ClickUpCustomField],
283        target_field_id: &str,
284    ) -> Option<String> {
285        for field in custom_fields {
286            if field.id == target_field_id {
287                if let Some(value) = &field.value {
288                    // Converter valor JSON para string
289                    match value {
290                        serde_json::Value::String(s) => return Some(s.clone()),
291                        serde_json::Value::Number(n) => return Some(n.to_string()),
292                        serde_json::Value::Bool(b) => return Some(b.to_string()),
293                        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
294                            return Some(value.to_string());
295                        }
296                        serde_json::Value::Null => return None,
297                    }
298                }
299            }
300        }
301        None
302    }
303
304    /// Método de conveniência: busca folder usando campo específico de uma tarefa
305    ///
306    /// WORKFLOW COMPLETO:
307    /// 1. Extrai valor do campo customizado especificado
308    /// 2. Se encontrado, busca folder usando o valor
309    /// 3. Retorna resultado da busca
310    ///
311    /// EXEMPLO DE USO:
312    /// ```no_run
313    /// # use clickup::folders::SmartFolderFinder;
314    /// # use clickup::client::ClickUpClient;
315    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
316    /// let client = ClickUpClient::new("your_api_token".to_string())?;
317    /// let mut finder = SmartFolderFinder::new(client, "workspace_id".to_string());
318    /// let custom_fields = vec![];
319    /// let result = finder.find_folder_from_task_field(Some(&custom_fields), "info_2_field_id").await?;
320    /// # Ok(())
321    /// # }
322    /// ```
323    pub async fn find_folder_from_task_field(
324        &mut self,
325        custom_fields: Option<&Vec<ClickUpCustomField>>,
326        field_id: &str,
327    ) -> Result<Option<FolderSearchResult>> {
328        tracing::info!(
329            "🔍 Extraindo valor do campo '{}' para busca de folder",
330            field_id
331        );
332
333        if let Some(fields) = custom_fields {
334            if let Some(extracted_value) = Self::extract_custom_field_value(fields, field_id) {
335                tracing::info!("✅ Valor extraído: '{}'", extracted_value);
336                return self.find_folder_by_extracted_value(&extracted_value).await;
337            }
338        }
339
340        tracing::warn!("⚠️ Campo '{}' não encontrado ou vazio", field_id);
341        Ok(None)
342    }
343
344    /// Fase 1: Buscar folders via API do ClickUp
345    async fn search_folders_via_api(
346        &self,
347        normalized_client: &str,
348    ) -> Result<Option<FolderSearchResult>> {
349        tracing::info!("📡 Buscando folders via API do ClickUp...");
350
351        // GET /team/{team_id}/space (API v2)
352        // Nota: Na v2, usa-se "team" mas internamente chamamos de "workspace" para clareza
353        // Como não sabemos o space_id, vamos buscar em todos os spaces
354
355        let endpoint = format!("/team/{}/space", self.workspace_id);
356        let spaces: serde_json::Value = self.client.get_json(&endpoint).await?;
357
358        let spaces_array = spaces["spaces"].as_array().ok_or_else(|| {
359            ClickUpError::ValidationError("Campo 'spaces' não é array".to_string())
360        })?;
361
362        // Para cada space, buscar folders
363        let mut all_folders = Vec::new();
364        for space in spaces_array {
365            let space_id = space["id"]
366                .as_str()
367                .ok_or_else(|| ClickUpError::ValidationError("Space sem ID".to_string()))?;
368
369            match self.fetch_folders_from_space(space_id).await {
370                Ok(folders) => all_folders.extend(folders),
371                Err(e) => {
372                    tracing::warn!("⚠️ Erro ao buscar folders do space {}: {}", space_id, e);
373                }
374            }
375        }
376
377        tracing::info!("📁 Total de folders encontrados: {}", all_folders.len());
378
379        // Buscar melhor match usando fuzzy matching
380        self.find_best_folder_match(normalized_client, &all_folders)
381            .await
382    }
383
384    /// Buscar folders de um space específico
385    async fn fetch_folders_from_space(&self, space_id: &str) -> Result<Vec<ClickUpFolder>> {
386        let endpoint = format!("/space/{}/folder", space_id);
387        let folders_response: ClickUpFoldersResponse = self.client.get_json(&endpoint).await?;
388        Ok(folders_response.folders)
389    }
390
391    /// Encontrar melhor match usando fuzzy matching
392    ///
393    /// ESTRATÉGIAS DE COMPARAÇÃO POR NOME:
394    /// 1. Exact Match: Nomes normalizados idênticos (confiança 1.0)
395    /// 2. Fuzzy Match: Jaro-Winkler >= 0.70 entre nomes normalizados
396    /// 3. Token Match: 60%+ dos tokens principais coincidem (para "Breno/Leticia" vs "Leticia e Breno")
397    ///
398    /// MOTIVAÇÃO: Garante que a busca é baseada SOMENTE no nome da pasta do ClickUp,
399    /// sem depender de metadados ou campos customizados das tarefas.
400    async fn find_best_folder_match(
401        &self,
402        normalized_client: &str,
403        folders: &[ClickUpFolder],
404    ) -> Result<Option<FolderSearchResult>> {
405        let mut best_match: Option<(ClickUpFolder, f64, SearchMethod)> = None;
406
407        for folder in folders {
408            let normalized_folder = Self::normalize_name(&folder.name);
409
410            // 1. Exact match
411            if normalized_folder == normalized_client {
412                tracing::info!("✅ Match exato: '{}'", folder.name);
413                best_match = Some((folder.clone(), 1.0, SearchMethod::ExactMatch));
414                break;
415            }
416
417            // 2. Fuzzy match (Jaro-Winkler)
418            let similarity = strsim::jaro_winkler(normalized_client, &normalized_folder);
419
420            tracing::debug!(
421                "  Comparando: '{}' vs '{}' → score: {:.3}",
422                normalized_client,
423                normalized_folder,
424                similarity
425            );
426
427            if similarity >= FUZZY_THRESHOLD {
428                if let Some((_, best_score, _)) = &best_match {
429                    if similarity > *best_score {
430                        best_match = Some((folder.clone(), similarity, SearchMethod::FuzzyMatch));
431                    }
432                } else {
433                    best_match = Some((folder.clone(), similarity, SearchMethod::FuzzyMatch));
434                }
435            }
436
437            // 3. Token-based matching (para casos como "Breno / Leticia" → "Leticia e Breno")
438            // Verifica se os principais tokens estão presentes, independente da ordem
439            if best_match.is_none()
440                || best_match
441                    .as_ref()
442                    .map(|(_, score, _)| *score)
443                    .unwrap_or(0.0)
444                    < 0.90
445            {
446                let client_tokens = Self::extract_name_tokens(normalized_client);
447                let folder_tokens = Self::extract_name_tokens(&normalized_folder);
448
449                if !client_tokens.is_empty() && !folder_tokens.is_empty() {
450                    let matching_tokens = client_tokens
451                        .iter()
452                        .filter(|ct| {
453                            folder_tokens
454                                .iter()
455                                .any(|ft| strsim::jaro_winkler(ct, ft) >= 0.90)
456                        })
457                        .count();
458
459                    let token_score = matching_tokens as f64
460                        / client_tokens.len().max(folder_tokens.len()) as f64;
461
462                    if token_score >= 0.60 {
463                        // Pelo menos 60% dos tokens devem dar match
464                        tracing::debug!(
465                            "  Token match: {}/{} tokens → score: {:.3}",
466                            matching_tokens,
467                            client_tokens.len().max(folder_tokens.len()),
468                            token_score
469                        );
470
471                        if let Some((_, best_score, _)) = &best_match {
472                            if token_score > *best_score {
473                                best_match =
474                                    Some((folder.clone(), token_score, SearchMethod::FuzzyMatch));
475                            }
476                        } else {
477                            best_match =
478                                Some((folder.clone(), token_score, SearchMethod::FuzzyMatch));
479                        }
480                    }
481                }
482            }
483        }
484
485        if let Some((folder, score, method)) = best_match {
486            // Buscar lista do mês atual
487            let (list_id, list_name) = self.find_or_create_current_month_list(&folder.id).await?;
488
489            Ok(Some(FolderSearchResult {
490                folder_id: folder.id,
491                folder_name: folder.name,
492                list_id: Some(list_id),
493                list_name: Some(list_name),
494                confidence: score,
495                search_method: method,
496            }))
497        } else {
498            Ok(None)
499        }
500    }
501
502    /// Fase 2: Buscar em tarefas anteriores (FUNCIONALIDADE DESCONTINUADA)
503    ///
504    /// MOTIVAÇÃO DA DESCONTINUAÇÃO:
505    /// - Campo "Cliente Solicitante" foi removido do sistema
506    /// - Busca agora depende EXCLUSIVAMENTE dos nomes das pastas (API /folder)
507    /// - Eliminada dependência de campos customizados para determinação de estrutura
508    ///
509    /// IMPACTO: Sistema mais robusto e independente de configurações de campos personalizados
510    async fn search_historical_tasks(
511        &self,
512        normalized_client: &str,
513    ) -> Result<Option<FolderSearchResult>> {
514        tracing::info!(
515            "🕐 Buscando tarefas históricas para cliente = '{}' (funcionalidade descontinuada)",
516            normalized_client
517        );
518
519        // GET /team/{team_id}/task with query params (API v2)
520        // Nota: Na v2, usa-se "team" mas internamente chamamos de "workspace"
521        let endpoint = format!(
522            "/team/{}/task?archived=false&subtasks=false&include_closed=true",
523            self.workspace_id
524        );
525        let tasks_response: ClickUpTasksResponse = self.client.get_json(&endpoint).await?;
526
527        tracing::info!(
528            "📋 Total de tarefas encontradas: {}",
529            tasks_response.tasks.len()
530        );
531
532        // FUNCIONALIDADE DESCONTINUADA: Campo "Cliente Solicitante" foi removido
533        // Retorna imediatamente None pois não há mais campo para buscar
534        tracing::warn!(
535            "⚠️ Busca histórica descontinuada - campo 'Cliente Solicitante' removido do sistema"
536        );
537        Ok(None)
538
539        // Código original removido em 2025-11-07:
540        // - Loop através de tasks_response.tasks
541        // - Verificação de field.id == CLIENT_SOLICITANTE_FIELD_ID (constante removida)
542        // - Fuzzy matching via strsim::jaro_winkler()
543        // - Retorno de FolderSearchResult com SearchMethod::HistoricalMatch
544        
545        /*
546        for task in tasks_response.tasks {
547            if let Some(custom_fields) = task.custom_fields {
548                for field in custom_fields {
549                    // REMOVIDO: if field.id == CLIENT_SOLICITANTE_FIELD_ID {
550                    // ... resto do código removido
551                    }
552                }
553            }
554        }
555        */
556    }
557
558    /// Gera nome do mês em português e caixa alta (ex: "OUTUBRO 2025")
559    fn get_month_name_pt(&self, date: chrono::DateTime<Utc>) -> String {
560        let month = date.month();
561        let year = date.year();
562
563        let month_pt = match month {
564            1 => "JANEIRO",
565            2 => "FEVEREIRO",
566            3 => "MARÇO",
567            4 => "ABRIL",
568            5 => "MAIO",
569            6 => "JUNHO",
570            7 => "JULHO",
571            8 => "AGOSTO",
572            9 => "SETEMBRO",
573            10 => "OUTUBRO",
574            11 => "NOVEMBRO",
575            12 => "DEZEMBRO",
576            _ => "DESCONHECIDO",
577        };
578
579        format!("{} {}", month_pt, year)
580    }
581
582    /// Buscar ou criar lista do mês atual na folder
583    async fn find_or_create_current_month_list(&self, folder_id: &str) -> Result<(String, String)> {
584        let now = Utc::now();
585        let month_name_pt = self.get_month_name_pt(now); // Ex: "OUTUBRO 2025"
586        let month_number = now.month();
587
588        tracing::info!("📅 Buscando lista do mês atual: '{}'", month_name_pt);
589
590        // GET /folder/{folder_id}
591        let endpoint = format!("/folder/{}", folder_id);
592        let folder: serde_json::Value = self.client.get_json(&endpoint).await?;
593
594        // Meses em português para busca (aceita variações)
595        let months_pt = [
596            "janeiro",
597            "fevereiro",
598            "março",
599            "abril",
600            "maio",
601            "junho",
602            "julho",
603            "agosto",
604            "setembro",
605            "outubro",
606            "novembro",
607            "dezembro",
608        ];
609        let current_month_pt = months_pt[(month_number - 1) as usize];
610        let year_str = now.year().to_string();
611
612        // Buscar lista com nome do mês (aceita em português ou inglês, case-insensitive)
613        if let Some(lists) = folder["lists"].as_array() {
614            for list in lists {
615                if let Some(name) = list["name"].as_str() {
616                    let name_lower = name.to_lowercase();
617
618                    // Aceita: "OUTUBRO 2025", "outubro 2025", "October 2025", etc.
619                    if (name_lower.contains(current_month_pt)
620                        || name_lower.contains(&now.format("%B").to_string().to_lowercase()))
621                        && name_lower.contains(&year_str)
622                    {
623                        let list_id = list["id"].as_str().ok_or_else(|| {
624                            ClickUpError::ValidationError("Lista sem ID".to_string())
625                        })?;
626
627                        tracing::info!("✅ Lista do mês encontrada: {} (id: {})", name, list_id);
628                        return Ok((list_id.to_string(), name.to_string()));
629                    }
630                }
631            }
632        }
633
634        // Lista não encontrada, criar nova em português e caixa alta
635        tracing::info!("📝 Criando lista do mês: '{}'", month_name_pt);
636        self.create_list(folder_id, &month_name_pt).await
637    }
638
639    /// Criar lista na folder
640    async fn create_list(&self, folder_id: &str, list_name: &str) -> Result<(String, String)> {
641        let endpoint = format!("/folder/{}/list", folder_id);
642        let payload = serde_json::json!({
643            "name": list_name,
644            "content": format!("Lista criada automaticamente para {}", list_name),
645        });
646
647        let list: serde_json::Value = self.client.post_json(&endpoint, &payload).await?;
648
649        let list_id = list["id"]
650            .as_str()
651            .ok_or_else(|| ClickUpError::ValidationError("Lista criada sem ID".to_string()))?;
652
653        tracing::info!(
654            "✅ Lista criada com sucesso: {} (id: {})",
655            list_name,
656            list_id
657        );
658
659        // Aguardar 2 segundos para ClickUp configurar custom fields da lista
660        tracing::debug!("⏳ Aguardando 2s para custom fields serem configurados...");
661        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
662
663        Ok((list_id.to_string(), list_name.to_string()))
664    }
665
666    /// Normalizar nome: lowercase, remover acentos, números e pontuação
667    pub fn normalize_name(name: &str) -> String {
668        use deunicode::deunicode;
669
670        // Substituir "/" e outros separadores por espaço antes de processar
671        let normalized = name.replace(['/', '\\', '|', '-'], " ");
672
673        // Remover acentos, converter para lowercase, remover caracteres especiais
674        deunicode(&normalized)
675            .to_lowercase()
676            .chars()
677            .filter(|c| c.is_alphanumeric() || c.is_whitespace())
678            .collect::<String>()
679            .split_whitespace()
680            .collect::<Vec<&str>>()
681            .join(" ")
682    }
683
684    /// Extrai tokens individuais de um nome para matching mais flexível
685    /// Exemplo: "Breno / Leticia" → ["breno", "leticia"]
686    fn extract_name_tokens(name: &str) -> Vec<String> {
687        Self::normalize_name(name)
688            .split_whitespace()
689            .filter(|token| token.len() > 2) // Ignorar tokens muito curtos como "e", "de"
690            .map(|s| s.to_string())
691            .collect()
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn test_normalize_name() {
701        assert_eq!(
702            SmartFolderFinder::normalize_name("Raphaela Spielberg"),
703            "raphaela spielberg"
704        );
705        assert_eq!(
706            SmartFolderFinder::normalize_name("José Muritiba (123)"),
707            "jose muritiba 123"
708        );
709        assert_eq!(
710            SmartFolderFinder::normalize_name("Gabriel Benarros!!!"),
711            "gabriel benarros"
712        );
713
714        // Novos testes para "/" e separadores
715        assert_eq!(
716            SmartFolderFinder::normalize_name("Breno / Leticia"),
717            "breno leticia"
718        );
719        assert_eq!(
720            SmartFolderFinder::normalize_name("Leticia e Breno"),
721            "leticia e breno"
722        );
723        assert_eq!(
724            SmartFolderFinder::normalize_name("Carlos | Pedro"),
725            "carlos pedro"
726        );
727        assert_eq!(SmartFolderFinder::normalize_name("Ana-Paula"), "ana paula");
728    }
729
730    #[test]
731    fn test_extract_name_tokens() {
732        let tokens = SmartFolderFinder::extract_name_tokens("Breno / Leticia");
733        assert_eq!(tokens, vec!["breno", "leticia"]);
734
735        let tokens2 = SmartFolderFinder::extract_name_tokens("Leticia e Breno");
736        assert_eq!(tokens2, vec!["leticia", "breno"]);
737
738        let tokens3 = SmartFolderFinder::extract_name_tokens("José de Oliveira");
739        assert_eq!(tokens3, vec!["jose", "oliveira"]); // "de" é filtrado por ser muito curto
740    }
741
742    #[test]
743    fn test_extract_custom_field_value() {
744        use serde_json::Value;
745
746        // Simular campos customizados
747        let custom_fields = vec![
748            ClickUpCustomField {
749                id: "info_1".to_string(),
750                value: Some(Value::String("Valor Info 1".to_string())),
751            },
752            ClickUpCustomField {
753                id: "info_2".to_string(),
754                value: Some(Value::String("João Silva".to_string())),
755            },
756            ClickUpCustomField {
757                id: "priority".to_string(),
758                value: Some(Value::Number(serde_json::Number::from(3))),
759            },
760            ClickUpCustomField {
761                id: "active".to_string(),
762                value: Some(Value::Bool(true)),
763            },
764            ClickUpCustomField {
765                id: "empty_field".to_string(),
766                value: None,
767            },
768        ];
769
770        // Teste: extrair campo string
771        assert_eq!(
772            SmartFolderFinder::extract_custom_field_value(&custom_fields, "info_2"),
773            Some("João Silva".to_string())
774        );
775
776        // Teste: extrair campo numérico
777        assert_eq!(
778            SmartFolderFinder::extract_custom_field_value(&custom_fields, "priority"),
779            Some("3".to_string())
780        );
781
782        // Teste: extrair campo booleano
783        assert_eq!(
784            SmartFolderFinder::extract_custom_field_value(&custom_fields, "active"),
785            Some("true".to_string())
786        );
787
788        // Teste: campo não encontrado
789        assert_eq!(
790            SmartFolderFinder::extract_custom_field_value(&custom_fields, "inexistente"),
791            None
792        );
793
794        // Teste: campo vazio
795        assert_eq!(
796            SmartFolderFinder::extract_custom_field_value(&custom_fields, "empty_field"),
797            None
798        );
799    }
800}