Skip to main content

kasl/api/
si.rs

1//! Internal SiServer API client for company reporting and calendar integration.
2//!
3//! Provides integration with an internal company API system that handles employee
4//! time tracking reports and company calendar information.
5//!
6//! ## Features
7//!
8//! - **Report Submission**: Submit daily and monthly time tracking reports
9//! - **Calendar Integration**: Fetch company rest dates and holidays
10//! - **Two-Stage Authentication**: LDAP authentication followed by session token exchange
11//! - **Error Resilience**: Graceful handling of network failures and API errors
12//! - **Session Management**: Automatic session caching and renewal
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use kasl::api::{Si, SiConfig};
18//! use chrono::Local;
19//!
20//! let config = SiConfig {
21//!     login: "username".to_string(),
22//!     auth_url: "https://auth.company.com".to_string(),
23//!     api_url: "https://api.company.com".to_string(),
24//! };
25//!
26//! let mut si = Si::new(&config);
27//! let today = Local::now().date_naive();
28//! let rest_dates = si.rest_dates(today).await?;
29//! ```
30
31use crate::{
32    api::Session,
33    libs::{config::ConfigModule, messages::Message, secret::Secret},
34    msg_error, msg_print,
35};
36use anyhow::Result;
37use base64::prelude::*;
38use chrono::{Datelike, Duration, NaiveDate, Weekday};
39use dialoguer::{Input, theme::ColorfulTheme};
40use reqwest::{
41    Client, StatusCode,
42    header::{self, COOKIE, HeaderMap, HeaderValue},
43    multipart,
44};
45use serde::{Deserialize, Serialize};
46use std::collections::HashSet;
47
48/// Maximum number of authentication retries before giving up.
49/// SiServer has more complex auth flow, so we use the same conservative limit.
50const MAX_RETRY_COUNT: i32 = 3;
51
52/// Cookie name prefix used by SiServer for session identification.
53const COOKIE_KEY: &str = "PORTALSESSID=";
54
55/// Filename for storing SiServer session tokens in the user data directory.
56const SESSION_ID_FILE: &str = ".si_session_id";
57
58/// Filename for storing encrypted SiServer credentials for password caching.
59const SECRET_FILE: &str = ".si_secret";
60
61/// SiServer API endpoint for LDAP authentication (first stage).
62const AUTH_URL: &str = "auth/ldap";
63
64/// SiServer API endpoint for token-to-session exchange (second stage).
65const LOGIN_URL: &str = "auth/login-by-token";
66
67/// SiServer API endpoint for submitting daily time reports.
68const REPORT_URL: &str = "report-card/send-daily-report";
69
70/// SiServer API endpoint for submitting monthly summary reports.
71const MONTHLY_REPORT_URL: &str = "report-card/send-monthly-report";
72
73/// SiServer API endpoint for fetching company rest dates and holidays.
74const REST_DATES_URL: &str = "report-card/get-rest-dates";
75
76/// User credentials for SiServer authentication.
77///
78/// SiServer requires special password encoding (double base64) for security.
79/// Credentials are only held in memory during the authentication process.
80#[derive(Serialize, Clone, Debug)]
81pub struct LoginCredentials {
82    /// Username for LDAP authentication
83    login: String,
84    /// Double base64-encoded password for enhanced security
85    password: String,
86}
87
88/// Response structure for SiServer LDAP authentication.
89///
90/// The first stage of authentication returns a temporary token that must
91/// be exchanged for a session cookie in the second stage.
92#[derive(Deserialize)]
93pub struct AuthSession {
94    /// Payload containing the authentication token
95    payload: AuthPayload,
96}
97
98/// Authentication payload containing the temporary token.
99///
100/// This token is used to authenticate the second stage of the login process
101/// where it's exchanged for a session cookie.
102#[derive(Deserialize)]
103pub struct AuthPayload {
104    /// Temporary authentication token for session exchange
105    token: String,
106}
107
108/// Response structure for SiServer rest dates API.
109///
110/// SiServer provides company calendar information including various types
111/// of non-working days such as holidays, vacation days, and weekend days.
112/// Different date arrays represent different types of rest periods.
113#[derive(Debug, Deserialize)]
114pub struct RestDatesResponse {
115    /// Regular rest dates (general holidays)
116    dates: Vec<String>,
117    /// Vacation dates (company-specific holidays)
118    v_dates: Vec<String>,
119    /// Weekend dates (extended weekend periods)
120    w_dates: Vec<String>,
121}
122
123impl RestDatesResponse {
124    /// Parses and combines all rest dates into a single unified set.
125    ///
126    /// This method processes all three categories of rest dates and combines them
127    /// into a single `HashSet` for easy lookup operations. Duplicate dates across
128    /// categories are automatically deduplicated.
129    ///
130    /// ## Date Format Handling
131    ///
132    /// The API returns dates in "YYYY-MM-DD" format. Invalid date strings are
133    /// silently ignored to handle potential API inconsistencies gracefully.
134    ///
135    /// # Returns
136    ///
137    /// * `Result<HashSet<NaiveDate>>` - Unified set of all rest dates
138    ///
139    /// # Errors
140    ///
141    /// Currently cannot fail, but returns `Result` for future error handling
142    /// such as date validation or API response verification.
143    pub fn unique_dates(&self) -> Result<HashSet<NaiveDate>> {
144        let mut date_set = HashSet::new();
145
146        // Process all three date categories
147        self.process_dates(&self.dates, &mut date_set)?;
148        self.process_dates(&self.v_dates, &mut date_set)?;
149        self.process_dates(&self.w_dates, &mut date_set)?;
150
151        Ok(date_set)
152    }
153
154    /// Helper function to parse date strings and add them to the result set.
155    ///
156    /// Processes a vector of date strings, attempting to parse each one into
157    /// a `NaiveDate`. Invalid dates are silently skipped to handle API
158    /// inconsistencies without failing the entire operation.
159    ///
160    /// # Arguments
161    ///
162    /// * `dates` - Vector of date strings in "YYYY-MM-DD" format
163    /// * `date_set` - Mutable reference to the result set for adding parsed dates
164    ///
165    /// # Returns
166    ///
167    /// Always returns `Ok(())` as this operation cannot fail.
168    fn process_dates(&self, dates: &[String], date_set: &mut HashSet<NaiveDate>) -> Result<()> {
169        dates
170            .iter()
171            .filter_map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok())
172            .for_each(|date| {
173                date_set.insert(date);
174            });
175        Ok(())
176    }
177}
178
179/// SiServer API client with advanced session management.
180///
181/// This client handles the complex two-stage authentication flow required by
182/// SiServer and provides methods for report submission and calendar data retrieval.
183/// It implements resilient error handling to ensure application stability.
184///
185/// ## Thread Safety
186///
187/// The client is not thread-safe due to mutable retry state. Each thread
188/// should use its own client instance for concurrent operations.
189///
190/// ## Authentication Architecture
191///
192/// SiServer uses a sophisticated authentication system:
193/// 1. **LDAP Stage**: Credentials sent to LDAP endpoint, token received
194/// 2. **Session Stage**: Token sent to session endpoint, cookie received
195/// 3. **API Usage**: Cookie included in all subsequent API requests
196///
197/// This design provides enhanced security but requires careful session management.
198#[derive(Debug)]
199pub struct Si {
200    /// HTTP client for making API requests with connection pooling
201    client: Client,
202    /// Configuration containing API endpoints and user information
203    config: SiConfig,
204    /// In-memory storage for authentication credentials during auth process
205    credentials: Option<LoginCredentials>,
206    /// Counter for tracking authentication retry attempts
207    retries: i32,
208}
209
210impl Session for Si {
211    /// Performs two-stage authentication with SiServer.
212    ///
213    /// This method implements SiServer's unique authentication flow which requires
214    /// two separate API calls to establish a session. The process is more complex
215    /// than standard session authentication but provides enhanced security.
216    ///
217    /// ## Authentication Process
218    ///
219    /// 1. **LDAP Authentication**: Send credentials to LDAP endpoint
220    /// 2. **Token Extraction**: Parse authentication token from response
221    /// 3. **Session Exchange**: Send token to session endpoint with Bearer auth
222    /// 4. **Cookie Extraction**: Parse session cookie from Set-Cookie header
223    /// 5. **Format Preparation**: Extract session ID for use in subsequent requests
224    ///
225    /// ## Error Scenarios
226    ///
227    /// - LDAP authentication failure (invalid credentials)
228    /// - Token parsing failure (unexpected response format)
229    /// - Session exchange failure (token expired or invalid)
230    /// - Cookie extraction failure (missing or malformed Set-Cookie header)
231    ///
232    /// # Returns
233    ///
234    /// Returns the session ID string extracted from the PORTALSESSID cookie.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if:
239    /// - No credentials have been set (programming error)
240    /// - Network requests fail
241    /// - LDAP authentication fails
242    /// - Token or cookie parsing fails
243    /// - Authentication flow completes but no valid session is established
244    async fn login(&self) -> Result<String> {
245        // Ensure credentials are available for authentication
246        let credentials = self.credentials.clone().expect("Credentials not set!");
247
248        // Stage 1: LDAP Authentication
249        let auth_url = format!("{}/{}", self.config.auth_url, AUTH_URL);
250        let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
251        let auth_body = auth_res.text().await?;
252        let auth_session: AuthSession = serde_json::from_str(&auth_body)?;
253
254        // Stage 2: Token-to-Session Exchange
255        let login_url = format!("{}/{}", self.config.api_url, LOGIN_URL);
256        let login_res = self
257            .client
258            .post(login_url)
259            .header(header::AUTHORIZATION, format!("Bearer {}", auth_session.payload.token))
260            .send()
261            .await?;
262
263        // Stage 3: Cookie Extraction
264        if let Some(cookie) = login_res.headers().get("Set-Cookie")
265            && let Ok(cookie_val) = cookie.to_str()
266        {
267            // Find the PORTALSESSID cookie in the Set-Cookie header
268            if let Some(portalsessid) = cookie_val.split(";").find(|c| c.starts_with(COOKIE_KEY)) {
269                let session_id = portalsessid.trim_start_matches(COOKIE_KEY);
270                return Ok(session_id.to_string());
271            }
272        }
273
274        // Authentication completed but no valid session cookie was found
275        anyhow::bail!("Login failed")
276    }
277
278    /// Sets user credentials with SiServer-specific password encoding.
279    ///
280    /// SiServer requires passwords to be double base64-encoded for security.
281    /// This method handles the encoding and stores credentials in memory for
282    /// use during the authentication process.
283    ///
284    /// ## Password Encoding
285    ///
286    /// The password undergoes double base64 encoding:
287    /// 1. First encoding: `base64(password)`
288    /// 2. Second encoding: `base64(base64(password))`
289    ///
290    /// This provides additional security layers for credential transmission.
291    ///
292    /// # Arguments
293    ///
294    /// * `password` - The user's SiServer password in plain text
295    ///
296    /// # Returns
297    ///
298    /// Always returns `Ok(())` as encoding cannot fail.
299    fn set_credentials(&mut self, password: &str) -> Result<()> {
300        // Apply double base64 encoding as required by SiServer
301        let encoded_password = BASE64_STANDARD.encode(BASE64_STANDARD.encode(password));
302
303        self.credentials = Some(LoginCredentials {
304            login: self.config.login.to_string(),
305            password: encoded_password,
306        });
307        Ok(())
308    }
309
310    /// Returns the filename for storing SiServer session tokens.
311    ///
312    /// The session file is stored in the user's application data directory
313    /// and contains the cached session token for automatic login restoration.
314    fn session_id_file(&self) -> &str {
315        SESSION_ID_FILE
316    }
317
318    /// Returns a configured Secret instance for secure password prompting.
319    ///
320    /// The Secret manager handles secure password input with hidden characters
321    /// and optional encrypted caching in the user's data directory.
322    ///
323    /// # Returns
324    ///
325    /// A configured `Secret` instance with SiServer-specific prompts and file names.
326    fn secret(&self) -> Secret {
327        Secret::new(SECRET_FILE, "Enter your SiServer password")
328    }
329
330    /// Returns the current authentication retry count.
331    ///
332    /// Used by the session management system to track failed authentication
333    /// attempts and implement retry limits.
334    fn retry(&self) -> i32 {
335        self.retries
336    }
337
338    /// Increments the authentication retry counter.
339    ///
340    /// Called after each failed authentication attempt to track progress
341    /// toward the maximum retry limit.
342    fn inc_retry(&mut self) {
343        self.retries += 1;
344    }
345
346    /// Resets the authentication retry counter to zero.
347    ///
348    /// Called after successful authentication to ensure future session
349    /// requests start with a clean slate.
350    fn reset_retry(&mut self) {
351        self.retries = 0;
352    }
353}
354
355impl Si {
356    /// Creates a new SiServer API client instance.
357    ///
358    /// Initializes the HTTP client with default settings suitable for SiServer API
359    /// interactions. The client is configured for both JSON and multipart requests
360    /// to handle different SiServer endpoints appropriately.
361    ///
362    /// # Arguments
363    ///
364    /// * `config` - SiServer configuration containing API endpoints and user information
365    ///
366    /// # Examples
367    ///
368    /// ```rust,no_run
369    /// use kasl::api::{Si, SiConfig};
370    ///
371    /// let config = SiConfig {
372    ///     login: "username".to_string(),
373    ///     auth_url: "https://auth.company.com".to_string(),
374    ///     api_url: "https://api.company.com".to_string(),
375    /// };
376    /// let si = Si::new(&config);
377    /// ```
378    pub fn new(config: &SiConfig) -> Self {
379        Self {
380            client: Client::new(),
381            config: config.clone(),
382            credentials: None,
383            retries: 0,
384        }
385    }
386
387    /// Submits a daily time tracking report to SiServer.
388    ///
389    /// This method sends formatted daily report data to the SiServer API for
390    /// payroll and time tracking integration. It handles session management
391    /// and implements retry logic for authentication failures.
392    ///
393    /// ## Report Format
394    ///
395    /// The report data should be a JSON string containing:
396    /// - Work hours and break information
397    /// - Task completion details
398    /// - Productivity metrics
399    /// - Any relevant metadata for the specified date
400    ///
401    /// ## Session Management
402    ///
403    /// The method implements automatic session handling:
404    /// 1. **Session Retrieval**: Get or create a valid session token
405    /// 2. **Report Submission**: Send report data with session authentication
406    /// 3. **Error Handling**: Detect expired sessions and retry with re-authentication
407    /// 4. **Status Return**: Return HTTP status for caller handling
408    ///
409    /// # Arguments
410    ///
411    /// * `data` - JSON string containing the formatted report data
412    /// * `date` - The date for which the report is being submitted
413    ///
414    /// # Returns
415    ///
416    /// Returns the HTTP status code from the API response, allowing callers
417    /// to determine success or specific failure modes.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error if:
422    /// - Session management fails persistently
423    /// - Network request fails
424    /// - Request formatting fails
425    /// - Duration conversion fails (internal error)
426    ///
427    /// # Examples
428    ///
429    /// ```rust,no_run
430    /// # use kasl::api::{Si, SiConfig};
431    /// # use chrono::Local;
432    /// # use anyhow::Result;
433    /// # async fn example() -> Result<()> {
434    /// let mut si = Si::new(&config);
435    /// let report_data = r#"{"hours": 8, "tasks": 5}"#.to_string();
436    /// let today = Local::now().date_naive();
437    ///
438    /// let status = si.send(&report_data, &today).await?;
439    /// if status.is_success() {
440    ///     println!("Report submitted successfully");
441    /// }
442    /// # Ok(())
443    /// # }
444    /// ```
445    pub async fn send(&mut self, data: &str, date: &NaiveDate) -> Result<StatusCode> {
446        let mut local_retries = 0;
447        loop {
448            // Get valid session for API request
449            let session_id = self.get_session_id().await?;
450            let url = format!("{}/{}", self.config.api_url, REPORT_URL);
451            let date = date.format("%Y-%m-%d").to_string();
452
453            // Prepare multipart form data for submission
454            let form = multipart::Form::new()
455                .text("date", date)
456                .text("tasks", data.to_owned())
457                .text("comment", "")
458                .text("day_type", "1")
459                .text("duty", "0")
460                .text("only_save", "0");
461
462            // Set up authentication headers
463            let mut headers = HeaderMap::new();
464            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
465
466            // Submit the report
467            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
468                Ok(response) => response,
469                Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
470            };
471
472            // Handle response and potential session expiration
473            match res.status() {
474                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
475                    // Session expired - clear cache and retry
476                    self.delete_session_id()?;
477                    tokio::time::sleep(Duration::seconds(1).to_std()?).await;
478                    local_retries += 1;
479                    continue;
480                }
481                _ => return Ok(res.status()),
482            }
483        }
484    }
485
486    /// Submits a monthly summary report to SiServer.
487    ///
488    /// Sends aggregated monthly statistics to the SiServer API for organizational
489    /// reporting and payroll integration. The report covers the entire month
490    /// containing the specified date.
491    ///
492    /// ## Monthly Report Contents
493    ///
494    /// The system automatically generates a summary containing:
495    /// - Total working hours for the month
496    /// - Number of working days
497    /// - Average daily productivity
498    /// - Compliance with company policies
499    ///
500    /// ## Last Working Day Logic
501    ///
502    /// Monthly reports are typically submitted on the last working day of each month.
503    /// The system can automatically detect this condition and prompt for submission.
504    ///
505    /// # Arguments
506    ///
507    /// * `date` - Any date within the target month for report generation
508    ///
509    /// # Returns
510    ///
511    /// Returns the HTTP status code from the API response.
512    ///
513    /// # Examples
514    ///
515    /// ```rust,no_run
516    /// # use kasl::api::{Si, SiConfig};
517    /// # use chrono::Local;
518    /// # use anyhow::Result;
519    /// # async fn example() -> Result<()> {
520    /// let mut si = Si::new(&config);
521    /// let today = Local::now().date_naive();
522    ///
523    /// if si.is_last_working_day_of_month(&today)? {
524    ///     let status = si.send_monthly(&today).await?;
525    ///     if status.is_success() {
526    ///         println!("Monthly report submitted");
527    ///     }
528    /// }
529    /// # Ok(())
530    /// # }
531    /// ```
532    pub async fn send_monthly(&mut self, date: &NaiveDate) -> Result<StatusCode> {
533        let mut local_retries = 0;
534        loop {
535            // Get valid session for API request
536            let session_id = self.get_session_id().await?;
537            let url = format!("{}/{}", self.config.api_url, MONTHLY_REPORT_URL);
538            let (year, month) = (date.year(), date.month());
539
540            // Prepare monthly report form data
541            let form = multipart::Form::new().text("month", month.to_string()).text("year", year.to_string());
542
543            // Set up authentication headers
544            let mut headers = HeaderMap::new();
545            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
546
547            // Submit the monthly report
548            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
549                Ok(response) => response,
550                Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
551            };
552
553            // Handle response and potential session expiration
554            match res.status() {
555                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
556                    // Session expired - clear cache and retry
557                    self.delete_session_id()?;
558                    tokio::time::sleep(Duration::seconds(1).to_std()?).await;
559                    local_retries += 1;
560                    continue;
561                }
562                _ => return Ok(res.status()),
563            }
564        }
565    }
566
567    /// Fetches company rest dates and holidays for the specified year.
568    ///
569    /// This method retrieves the official company calendar including holidays,
570    /// vacation days, and extended weekend periods. The data is used for accurate
571    /// productivity calculations and report generation.
572    ///
573    /// ## Error Resilience
574    ///
575    /// This function prioritizes application stability over data completeness:
576    /// - Network errors return empty results rather than failing
577    /// - Authentication failures are logged but don't interrupt operation
578    /// - API parsing errors result in empty calendar (graceful degradation)
579    /// - Session failures are handled with automatic retry
580    ///
581    /// This design ensures that calendar integration enhances functionality
582    /// without breaking core time tracking features when services are unavailable.
583    ///
584    /// ## Date Processing
585    ///
586    /// The API returns three categories of rest dates:
587    /// - Regular holidays (national and company holidays)
588    /// - Vacation dates (company-specific rest periods)
589    /// - Weekend extensions (long weekend periods)
590    ///
591    /// All categories are combined into a single set for unified processing.
592    ///
593    /// # Arguments
594    ///
595    /// * `year` - Any date within the target year for calendar retrieval
596    ///
597    /// # Returns
598    ///
599    /// Returns a `HashSet<NaiveDate>` containing all rest dates for the year.
600    /// Returns an empty set on any error to ensure graceful degradation.
601    ///
602    /// # Examples
603    ///
604    /// ```rust,no_run
605    /// # use kasl::api::{Si, SiConfig};
606    /// # use chrono::Local;
607    /// # use anyhow::Result;
608    /// # async fn example() -> Result<()> {
609    /// let mut si = Si::new(&config);
610    /// let this_year = Local::now().date_naive();
611    ///
612    /// let rest_dates = si.rest_dates(this_year).await?;
613    /// println!("Found {} rest dates this year", rest_dates.len());
614    ///
615    /// // Check if a specific date is a rest day
616    /// let today = Local::now().date_naive();
617    /// if rest_dates.contains(&today) {
618    ///     println!("Today is a company rest day");
619    /// }
620    /// # Ok(())
621    /// # }
622    /// ```
623    pub async fn rest_dates(&mut self, year: NaiveDate) -> Result<HashSet<NaiveDate>> {
624        let mut local_retries = 0;
625        loop {
626            // Get valid session for API request
627            let session_id = match self.get_session_id().await {
628                Ok(id) => id,
629                Err(e) => {
630                    msg_error!(Message::SiServerSessionFailed(e.to_string()));
631                    return Ok(HashSet::new()); // Return empty set on session failure
632                }
633            };
634
635            // Prepare rest dates request
636            let url = format!("{}/{}", self.config.api_url, REST_DATES_URL);
637            let form = multipart::Form::new().text("year", year.format("%Y").to_string());
638            let mut headers = HeaderMap::new();
639            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
640
641            // Request rest dates from API
642            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
643                Ok(resp) => resp,
644                Err(e) => {
645                    msg_error!(Message::SiServerRestDatesFailed(e.to_string()));
646                    return Ok(HashSet::new()); // Return empty set on network error
647                }
648            };
649
650            // Handle response and potential session expiration
651            match res.status() {
652                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
653                    // Session expired - clear cache and retry
654                    self.delete_session_id()?;
655                    local_retries += 1;
656                    continue;
657                }
658                _ => {
659                    // Process successful response or non-recoverable error
660                    return match res.json::<RestDatesResponse>().await {
661                        Ok(response) => Ok(response.unique_dates()?),
662                        Err(e) => {
663                            msg_error!(Message::SiServerRestDatesParsingFailed(e.to_string()));
664                            Ok(HashSet::new()) // Return empty set on parsing error
665                        }
666                    };
667                }
668            }
669        }
670    }
671
672    /// Determines if the specified date is the last working day of its month.
673    ///
674    /// This utility function calculates whether a given date represents the final
675    /// working day in its month, which is useful for triggering monthly report
676    /// submissions and other end-of-month processing.
677    ///
678    /// ## Algorithm
679    ///
680    /// The calculation process:
681    /// 1. **Find Month End**: Determine the last calendar day of the month
682    /// 2. **Weekend Adjustment**: Move backward from weekends to find working days
683    /// 3. **Comparison**: Check if the input date matches the calculated last working day
684    ///
685    /// ## Limitations
686    ///
687    /// Currently only considers weekends (Saturday/Sunday) as non-working days.
688    /// Future versions may integrate with the rest dates API to consider holidays
689    /// and company-specific non-working days for more accurate calculations.
690    ///
691    /// # Arguments
692    ///
693    /// * `date` - The date to check against the last working day
694    ///
695    /// # Returns
696    ///
697    /// Returns `true` if the date is the last working day of its month,
698    /// `false` otherwise.
699    ///
700    /// # Errors
701    ///
702    /// Currently cannot fail, but returns `Result` for consistency and
703    /// future enhancement with holiday integration.
704    ///
705    /// # Examples
706    ///
707    /// ```rust,no_run
708    /// # use kasl::api::{Si, SiConfig};
709    /// # use chrono::NaiveDate;
710    /// # use anyhow::Result;
711    /// # fn example() -> Result<()> {
712    /// let si = Si::new(&config);
713    /// let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); // January 31st
714    ///
715    /// if si.is_last_working_day_of_month(&date)? {
716    ///     println!("Time to submit monthly report!");
717    /// }
718    /// # Ok(())
719    /// # }
720    /// ```
721    pub fn is_last_working_day_of_month(&self, date: &NaiveDate) -> Result<bool> {
722        let (year, month) = (date.year(), date.month());
723
724        // Calculate the last day of the current month
725        let mut last_day_of_month = NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap().pred_opt().unwrap();
726
727        // Move backward from weekends to find the last working day
728        while matches!(last_day_of_month.weekday(), Weekday::Sat | Weekday::Sun) {
729            last_day_of_month -= Duration::days(1);
730        }
731
732        // Check if the input date matches the calculated last working day
733        Ok(date == &last_day_of_month)
734    }
735}
736
737/// Configuration for SiServer API integration.
738///
739/// This structure holds the necessary information for connecting to internal
740/// SiServer systems. Unlike other API integrations, SiServer requires separate
741/// authentication and API endpoints due to its sophisticated security architecture.
742///
743/// ## Multi-Endpoint Architecture
744///
745/// SiServer uses different endpoints for different purposes:
746/// - **Authentication URL**: LDAP authentication endpoint
747/// - **API URL**: Main API endpoint for reports and data
748/// - **Separation Benefits**: Enhanced security, load distribution, service isolation
749///
750/// ## Security Considerations
751///
752/// - Passwords are never stored in configuration files
753/// - Only username and endpoints are persisted
754/// - Session tokens are cached separately with encryption
755/// - Double base64 password encoding for transmission security
756#[derive(Serialize, Deserialize, Clone, Debug)]
757pub struct SiConfig {
758    /// Username for SiServer authentication.
759    ///
760    /// This should be the corporate username used for LDAP authentication.
761    /// Typically matches the username used for other company systems.
762    pub login: String,
763
764    /// URL for the SiServer authentication endpoint.
765    ///
766    /// This endpoint handles LDAP authentication and token generation.
767    /// Example: `https://auth.company.com`
768    ///
769    /// This is separate from the main API URL due to SiServer's security architecture.
770    pub auth_url: String,
771
772    /// Base URL for the main SiServer API endpoints.
773    ///
774    /// This endpoint handles report submission and data retrieval operations.
775    /// Example: `https://api.company.com`
776    ///
777    /// All API operations (reports, calendar) use this base URL.
778    pub api_url: String,
779}
780
781impl SiConfig {
782    /// Returns the configuration module metadata for SiServer.
783    ///
784    /// Used by the configuration system to identify and manage
785    /// SiServer-specific settings during interactive setup.
786    ///
787    /// # Returns
788    ///
789    /// A `ConfigModule` with SiServer identification information.
790    pub fn module() -> ConfigModule {
791        ConfigModule {
792            key: "si".to_string(),
793            name: "SiServer".to_string(),
794        }
795    }
796
797    /// Runs an interactive configuration setup for SiServer integration.
798    ///
799    /// Prompts the user for SiServer connection details including username
800    /// and both authentication and API endpoints. Uses existing configuration
801    /// values as defaults if available.
802    ///
803    /// ## Interactive Prompts
804    ///
805    /// 1. **Username**: Corporate username for LDAP authentication
806    /// 2. **Authentication URL**: LDAP endpoint for token generation
807    /// 3. **API URL**: Main API endpoint for reports and data operations
808    ///
809    /// All prompts show existing values as defaults if configuration already
810    /// exists, making it easy to update specific values without re-entering everything.
811    ///
812    /// ## Configuration Validation
813    ///
814    /// While this method doesn't validate actual connectivity, it provides
815    /// helpful prompts to guide users toward correct configuration values
816    /// for their corporate SiServer deployment.
817    ///
818    /// # Arguments
819    ///
820    /// * `config` - Existing SiServer configuration to use as defaults (if any)
821    ///
822    /// # Returns
823    ///
824    /// * `Result<Self>` - New SiServer configuration with user input
825    ///
826    /// # Errors
827    ///
828    /// Returns an error if:
829    /// - Terminal input/output fails
830    /// - User cancels the configuration process
831    /// - Input validation fails
832    ///
833    /// # Example
834    ///
835    /// ```rust,no_run
836    /// # use kasl::api::SiConfig;
837    /// # use anyhow::Result;
838    /// # fn example() -> Result<()> {
839    /// let existing_config = Some(SiConfig {
840    ///     login: "olduser".to_string(),
841    ///     auth_url: "https://old-auth.com".to_string(),
842    ///     api_url: "https://old-api.com".to_string(),
843    /// });
844    ///
845    /// let new_config = SiConfig::init(&existing_config)?;
846    /// # Ok(())
847    /// # }
848    /// ```
849    pub fn init(config: &Option<SiConfig>) -> Result<Self> {
850        // Use existing configuration as defaults, or create empty defaults
851        let config = config.clone().unwrap_or(Self {
852            login: "".to_string(),
853            auth_url: "".to_string(),
854            api_url: "".to_string(),
855        });
856
857        // Display configuration module header
858        msg_print!(Message::ConfigModuleSiServer);
859
860        // Interactive configuration with existing values as defaults
861        Ok(Self {
862            login: Input::with_theme(&ColorfulTheme::default())
863                .with_prompt("Enter your SiServer login")
864                .default(config.login)
865                .interact_text()?,
866            auth_url: Input::with_theme(&ColorfulTheme::default())
867                .with_prompt("Enter your SiServer login URL")
868                .default(config.auth_url)
869                .interact_text()?,
870            api_url: Input::with_theme(&ColorfulTheme::default())
871                .with_prompt("Enter the SiServer API URL")
872                .default(config.api_url)
873                .interact_text()?,
874        })
875    }
876}