use crate::{
    Result,
    config::{ConfigFile, ConfigProfile},
    logging::{LogConfig, init_logging},
    paths::{AppPaths, PathOverrides},
    semesters,
    state::{CourseState, ProfileState, SemesterState, StateFile},
    studip_client::{Course, FileRef, Folder, SemesterData, SemesterResponse, StudipClient},
};
use anyhow::{Context, anyhow, bail};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use clap::{ArgAction, Parser, Subcommand, ValueHint};
use rpassword::prompt_password;
use sha2::{Digest, Sha256};
use std::{
    collections::{HashMap, HashSet},
    env,
    fmt::Write as FmtWrite,
    fs::{File, OpenOptions},
    io::{self, BufReader, Read, Write},
    path::{Path, PathBuf},
};
use time::{
    Date, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset,
    format_description::well_known::Rfc3339, macros::format_description,
};
use tokio::{fs, io::AsyncWriteExt};
use tracing::info;
use unicode_normalization::{UnicodeNormalization, char::is_combining_mark};
use walkdir::WalkDir;

const USERNAME_ENV: &str = "STUDIP_SYNC_USERNAME";
const PASSWORD_ENV: &str = "STUDIP_SYNC_PASSWORD";

#[derive(Debug, Parser)]
#[command(
    name = "studip-sync",
    about = "Synchronize Stud.IP documents to the local filesystem."
)]
pub struct Cli {
    #[arg(short = 'q', long = "quiet", action = ArgAction::SetTrue, global = true)]
    quiet: bool,
    #[arg(short = 'd', long = "debug", action = ArgAction::SetTrue, global = true)]
    debug: bool,
    #[arg(short = 'j', long = "json", action = ArgAction::SetTrue, global = true)]
    json: bool,
    #[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
    verbose: u8,
    #[arg(long = "config-dir", value_hint = ValueHint::DirPath, global = true)]
    config_dir: Option<PathBuf>,
    #[arg(long = "data-dir", value_hint = ValueHint::DirPath, global = true)]
    data_dir: Option<PathBuf>,
    #[arg(long = "profile", global = true)]
    profile: Option<String>,
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Configure credentials and other persistent settings.
    Auth(AuthArgs),
    /// Write a default config template to disk.
    InitConfig(InitConfigArgs),
    /// Perform a one-way sync from Stud.IP to the local filesystem.
    Sync(SyncArgs),
    /// Show known courses, optionally refreshing from Stud.IP.
    ListCourses(ListCoursesArgs),
}

#[derive(Debug, Parser)]
pub struct AuthArgs {
    #[arg(long = "non-interactive", action = ArgAction::SetTrue)]
    pub non_interactive: bool,
    #[arg(long = "username")]
    pub username: Option<String>,
    #[arg(long = "password")]
    pub password: Option<String>,
}

#[derive(Debug, Parser)]
pub struct InitConfigArgs {
    /// Overwrite an existing config file.
    #[arg(long = "force", action = ArgAction::SetTrue)]
    pub force: bool,
    /// Optional download root to bake into the template.
    #[arg(long = "download-root", value_hint = ValueHint::DirPath)]
    pub download_root: Option<PathBuf>,
}

#[derive(Debug, Parser)]
pub struct SyncArgs {
    #[arg(long = "dry-run", action = ArgAction::SetTrue)]
    pub dry_run: bool,
    #[arg(long = "prune", action = ArgAction::SetTrue)]
    pub prune: bool,
    #[arg(long = "since")]
    pub since: Option<String>,
}

#[derive(Debug, Parser)]
pub struct ListCoursesArgs {
    #[arg(long = "refresh", action = ArgAction::SetTrue)]
    pub refresh: bool,
}

impl Cli {
    pub async fn run(self) -> Result<()> {
        let log_cfg = LogConfig {
            quiet: self.quiet,
            debug: self.debug,
            json: self.json,
            verbosity: self.verbose,
        };
        init_logging(&log_cfg)?;

        let overrides = PathOverrides {
            config_dir: self.config_dir.clone(),
            data_dir: self.data_dir.clone(),
        };
        let paths = AppPaths::new(&overrides)?;
        paths.ensure_dirs()?;

        let config_path = paths.config_file();
        let state_path = paths.state_file();

        let mut config_store = ConfigFile::load_or_default(&config_path)?;
        let state_store = StateFile::load_or_default(&state_path)?;

        let profile_name = self
            .profile
            .clone()
            .unwrap_or_else(|| config_store.default_profile.clone());

        let config_preexisting = config_store.profiles.contains_key(&profile_name);
        config_store
            .profiles
            .entry(profile_name.clone())
            .or_insert_with(ConfigProfile::default);

        let mut ctx = CommandContext::new(
            paths,
            profile_name,
            config_store,
            state_store,
            config_path,
            state_path,
            !config_preexisting,
        );

        match self.command {
            Command::Auth(args) => args.execute(&mut ctx).await?,
            Command::InitConfig(args) => args.execute(&mut ctx).await?,
            Command::Sync(args) => args.execute(&mut ctx).await?,
            Command::ListCourses(args) => args.execute(&mut ctx).await?,
        }

        ctx.persist()?;

        Ok(())
    }
}

pub struct CommandContext {
    paths: AppPaths,
    active_profile: String,
    pub config: ConfigFile,
    pub state: StateFile,
    config_path: PathBuf,
    state_path: PathBuf,
    config_dirty: bool,
    state_dirty: bool,
}

impl CommandContext {
    fn new(
        paths: AppPaths,
        active_profile: String,
        config: ConfigFile,
        state: StateFile,
        config_path: PathBuf,
        state_path: PathBuf,
        config_dirty: bool,
    ) -> Self {
        Self {
            paths,
            active_profile,
            config,
            state,
            config_path,
            state_path,
            config_dirty,
            state_dirty: false,
        }
    }

    pub fn profile_name(&self) -> &str {
        &self.active_profile
    }

    pub fn paths(&self) -> &AppPaths {
        &self.paths
    }

    pub fn config_profile(&self) -> &ConfigProfile {
        self.config
            .profiles
            .get(&self.active_profile)
            .expect("active profile must exist")
    }

    pub fn config_profile_mut(&mut self) -> &mut ConfigProfile {
        self.config_dirty = true;
        self.config
            .profiles
            .get_mut(&self.active_profile)
            .expect("active profile must exist")
    }

    pub fn state_profile(&self) -> Option<&ProfileState> {
        self.state.profile(&self.active_profile)
    }

    pub fn semester_by_key(&self, key: &str) -> Option<&SemesterState> {
        self.state_profile()
            .and_then(|profile| profile.semesters.get(key))
    }

    pub fn state_profile_mut(&mut self) -> &mut ProfileState {
        self.state_dirty = true;
        self.state.profile_mut(&self.active_profile)
    }

    pub fn semester_key_by_id(&self, semester_id: &str) -> Option<String> {
        self.state_profile().and_then(|profile| {
            profile.semesters.iter().find_map(|(key, semester)| {
                if semester.id == semester_id {
                    Some(key.clone())
                } else {
                    None
                }
            })
        })
    }

    pub fn insert_semester(
        &mut self,
        preferred_key: String,
        mut semester: SemesterState,
    ) -> String {
        if let Some(existing_key) = self.semester_key_by_id(&semester.id) {
            if let Some(existing) = self.state_profile_mut().semesters.get_mut(&existing_key) {
                existing.title = semester.title.clone();
                if semester.start.is_some() {
                    existing.start = semester.start.clone();
                }
                if semester.end.is_some() {
                    existing.end = semester.end.clone();
                }
            }
            return existing_key;
        }

        let base_key = preferred_key;
        let mut key = base_key.clone();
        let mut counter = 2;
        loop {
            let collision = self
                .state_profile()
                .and_then(|profile| profile.semesters.get(&key))
                .map(|existing| existing.id != semester.id)
                .unwrap_or(false);

            if collision {
                key = format!("{base_key}-{counter}");
                counter += 1;
                continue;
            }
            break;
        }

        semester.key = key.clone();
        self.state_profile_mut()
            .semesters
            .insert(key.clone(), semester);
        key
    }

    pub fn upsert_course(&mut self, course: CourseState) {
        self.state_profile_mut()
            .courses
            .insert(course.id.clone(), course);
    }

    pub fn clear_courses(&mut self) {
        self.state_profile_mut().courses.clear();
    }

    pub fn mark_state_dirty(&mut self) {
        self.state_dirty = true;
    }

    pub fn persist(&mut self) -> Result<()> {
        if self.config_dirty {
            self.config.save(&self.config_path)?;
            self.config_dirty = false;
        }
        if self.state_dirty {
            self.state.save(&self.state_path)?;
            self.state_dirty = false;
        }
        Ok(())
    }
}

impl AuthArgs {
    async fn execute(&self, ctx: &mut CommandContext) -> Result<()> {
        let (username, password) = self.collect_credentials()?;
        let encoded = BASE64.encode(format!("{username}:{password}"));
        let profile = ctx.config_profile_mut();
        profile.username = Some(username.clone());
        profile.basic_auth_b64 = Some(encoded);

        info!(
            profile = ctx.profile_name(),
            username = username.as_str(),
            "stored Stud.IP credentials"
        );
        Ok(())
    }

    fn collect_credentials(&self) -> Result<(String, String)> {
        let mut username = self.username_from_sources();
        let mut password = self.password_from_sources();

        if self.non_interactive {
            let username = username.ok_or_else(|| {
                anyhow!("non-interactive auth requires --username or {USERNAME_ENV}")
            })?;
            let password = password.ok_or_else(|| {
                anyhow!("non-interactive auth requires --password or {PASSWORD_ENV}")
            })?;
            return Ok((username, password));
        }

        if username.is_none() {
            username = Some(prompt_username("Stud.IP username: ")?);
        }
        if password.is_none() {
            password = Some(prompt_secret("Stud.IP password: ")?);
        }

        let username = username.expect("username guaranteed");
        let password = password.expect("password guaranteed");
        Ok((username, password))
    }

    fn username_from_sources(&self) -> Option<String> {
        self.username
            .clone()
            .map(|value| value.trim().to_string())
            .filter(|value| !value.is_empty())
            .or_else(|| {
                env::var(USERNAME_ENV)
                    .ok()
                    .map(|value| value.trim().to_string())
                    .filter(|value| !value.is_empty())
            })
    }

    fn password_from_sources(&self) -> Option<String> {
        self.password
            .clone()
            .filter(|value| !value.is_empty())
            .or_else(|| {
                env::var(PASSWORD_ENV)
                    .ok()
                    .filter(|value| !value.is_empty())
            })
    }
}

impl InitConfigArgs {
    async fn execute(&self, ctx: &mut CommandContext) -> Result<()> {
        let config_path = ctx.paths().config_file();
        if config_path.exists() && !self.force {
            bail!(
                "Config file {} already exists. Use --force to overwrite.",
                config_path.display()
            );
        }

        let download_root = self
            .download_root
            .clone()
            .unwrap_or_else(|| ctx.paths().data_dir.join("downloads"));
        let template = render_default_config_template(&download_root);

        if let Some(parent) = config_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let mut file = OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(true)
            .open(&config_path)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = file.metadata()?.permissions();
            perms.set_mode(0o600);
            file.set_permissions(perms)?;
        }
        file.write_all(template.as_bytes())?;

        info!(
            profile = ctx.profile_name(),
            path = %config_path.display(),
            "wrote default config template"
        );
        Ok(())
    }
}

fn prompt_username(prompt: &str) -> Result<String> {
    let mut stdout = io::stdout();
    stdout.write_all(prompt.as_bytes())?;
    stdout.flush()?;

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let value = input.trim().to_string();
    if value.is_empty() {
        bail!("username cannot be empty");
    }
    Ok(value)
}

fn prompt_secret(prompt: &str) -> Result<String> {
    let value = prompt_password(prompt)?;
    if value.is_empty() {
        bail!("password cannot be empty");
    }
    Ok(value)
}

impl SyncArgs {
    async fn execute(&self, ctx: &mut CommandContext) -> Result<()> {
        let client = StudipClient::from_profile(ctx.config_profile())?;
        let user_id = resolve_user_id(ctx, &client, false).await?;
        let courses = client.list_courses(&user_id).await?;

        ensure_semesters_cached(ctx, &client, &courses).await?;

        let since_filter = resolve_since_filter(ctx, self.since.as_deref())?;

        let fallback_root = ctx.paths().data_dir.join("downloads");
        let download_root = ctx
            .config_profile()
            .download_root
            .clone()
            .unwrap_or(fallback_root);

        if !self.dry_run {
            fs::create_dir_all(&download_root).await?;
        }

        let mut remote_files = HashSet::new();
        let mut stats = SyncStats::default();
        let mut name_registry = NameRegistry::default();

        for course in courses {
            sync_course(
                ctx,
                &client,
                &course,
                &download_root,
                &mut name_registry,
                self,
                since_filter,
                &mut remote_files,
                &mut stats,
            )
            .await?;
        }

        if self.prune {
            let prune = prune_local(&download_root, &remote_files, self.dry_run)?;
            stats.pruned_files = prune.removed_files;
            stats.pruned_dirs = prune.removed_dirs;
        }

        info!(
            profile = ctx.profile_name(),
            dry_run = self.dry_run,
            prune = self.prune,
            downloaded = stats.downloaded,
            skipped = stats.skipped,
            planned = stats.planned,
            skipped_since = stats.skipped_since,
            pruned_files = stats.pruned_files,
            pruned_dirs = stats.pruned_dirs,
            since = self.since.as_deref().unwrap_or(""),
            "sync completed"
        );
        Ok(())
    }
}

impl ListCoursesArgs {
    async fn execute(&self, ctx: &mut CommandContext) -> Result<()> {
        let client = StudipClient::from_profile(ctx.config_profile())?;
        let user_id = resolve_user_id(ctx, &client, self.refresh).await?;
        let courses = client.list_courses(&user_id).await?;

        ensure_semesters_cached(ctx, &client, &courses).await?;

        let previous_courses = ctx
            .state_profile()
            .map(|profile| profile.courses.clone())
            .unwrap_or_default();

        if self.refresh {
            ctx.clear_courses();
        }

        let mut summaries = Vec::new();

        for course in courses {
            let semester_key = course
                .relationships
                .semester_id()
                .and_then(|sem_id| ctx.semester_key_by_id(sem_id))
                .unwrap_or_else(|| "unknown".to_string());

            let previous_sync = previous_courses
                .get(&course.id)
                .and_then(|existing| existing.last_sync.clone());

            let display_title = course_display_title(&course);
            let summary = CourseSummary {
                semester_key: semester_key.clone(),
                course_id: course.id.clone(),
                course_number: course.attributes.course_number.clone(),
                title: display_title.clone(),
            };

            ctx.upsert_course(CourseState {
                id: course.id.clone(),
                name: display_title,
                semester_key,
                last_sync: previous_sync,
            });

            summaries.push(summary);
        }

        summaries.sort_by(|a, b| {
            a.semester_key
                .cmp(&b.semester_key)
                .then_with(|| a.title.cmp(&b.title))
        });

        if summaries.is_empty() {
            println!("No courses found on Stud.IP.");
        } else {
            for summary in &summaries {
                println!(
                    "{:<8} {:<12} {:<12} {}",
                    summary.semester_key,
                    summary
                        .course_number
                        .as_deref()
                        .filter(|s| !s.is_empty())
                        .unwrap_or("-"),
                    summary.course_id,
                    summary.title
                );
            }
        }

        info!(
            profile = ctx.profile_name(),
            refresh = self.refresh,
            count = summaries.len(),
            "listed courses"
        );
        Ok(())
    }
}

struct CourseSummary {
    semester_key: String,
    course_id: String,
    course_number: Option<String>,
    title: String,
}

async fn resolve_user_id(
    ctx: &mut CommandContext,
    client: &StudipClient,
    force_refresh: bool,
) -> Result<String> {
    if !force_refresh
        && let Some(existing) = ctx
            .state_profile()
            .and_then(|profile| profile.user_id.clone())
    {
        return Ok(existing);
    }

    let response = client.current_user().await?;
    let user_id = response.data.id;
    ctx.state_profile_mut().user_id = Some(user_id.clone());
    Ok(user_id)
}

async fn ensure_semesters_cached(
    ctx: &mut CommandContext,
    client: &StudipClient,
    courses: &[Course],
) -> Result<()> {
    let mut missing = HashSet::new();
    for course in courses {
        if let Some(semester_id) = course.relationships.semester_id() {
            let needs_fetch = match ctx.semester_key_by_id(semester_id) {
                Some(existing_key) => ctx
                    .semester_by_key(&existing_key)
                    .is_none_or(|semester| semester.start.is_none()),
                None => true,
            };

            if needs_fetch {
                missing.insert(semester_id.to_string());
            }
        }
    }

    for semester_id in missing {
        let SemesterResponse { data } = client.semester(&semester_id).await?;
        let SemesterData { id, attributes } = data;
        let title = attributes.title.clone();
        let preferred_key = semesters::infer_key(&title);
        ctx.insert_semester(
            preferred_key,
            SemesterState {
                id,
                title,
                key: String::new(),
                start: attributes.start.clone(),
                end: attributes.end.clone(),
            },
        );
    }

    Ok(())
}

fn course_display_title(course: &Course) -> String {
    match &course.attributes.subtitle {
        Some(subtitle) if !subtitle.is_empty() => {
            format!("{} – {}", course.attributes.title, subtitle)
        }
        _ => course.attributes.title.clone(),
    }
}

#[derive(Default)]
struct SyncStats {
    downloaded: usize,
    skipped: usize,
    planned: usize,
    skipped_since: usize,
    pruned_files: usize,
    pruned_dirs: usize,
}

#[derive(Default)]
struct PruneStats {
    removed_files: usize,
    removed_dirs: usize,
}

#[allow(clippy::too_many_arguments)]
async fn sync_course(
    ctx: &mut CommandContext,
    client: &StudipClient,
    course: &Course,
    download_root: &Path,
    name_registry: &mut NameRegistry,
    args: &SyncArgs,
    since: Option<OffsetDateTime>,
    remote_files: &mut HashSet<PathBuf>,
    stats: &mut SyncStats,
) -> Result<()> {
    let semester_key = course
        .relationships
        .semester_id()
        .and_then(|id| ctx.semester_key_by_id(id))
        .unwrap_or_else(|| "unknown".into());

    let semester_dir = download_root.join(&semester_key);
    ensure_directory(&semester_dir, args.dry_run).await?;

    let course_dir_name = name_registry.unique_dir_name(
        &semester_dir,
        normalize_component(&course.attributes.title),
        &course.id,
    );
    let course_dir = semester_dir.join(course_dir_name);
    ensure_directory(&course_dir, args.dry_run).await?;

    let folders = client.list_course_folders(&course.id).await?;
    for folder in folders {
        sync_folder(
            ctx,
            client,
            folder,
            &course_dir,
            download_root,
            name_registry,
            args,
            since,
            remote_files,
            stats,
        )
        .await?;
    }

    let last_sync = current_timestamp()?;
    ctx.upsert_course(CourseState {
        id: course.id.clone(),
        name: course_display_title(course),
        semester_key,
        last_sync: Some(last_sync),
    });

    Ok(())
}

#[allow(clippy::too_many_arguments)]
async fn sync_folder(
    ctx: &mut CommandContext,
    client: &StudipClient,
    folder: Folder,
    parent_path: &Path,
    download_root: &Path,
    name_registry: &mut NameRegistry,
    args: &SyncArgs,
    since: Option<OffsetDateTime>,
    remote_files: &mut HashSet<PathBuf>,
    stats: &mut SyncStats,
) -> Result<()> {
    let mut stack = vec![(folder, parent_path.to_path_buf())];

    while let Some((current_folder, parent)) = stack.pop() {
        let folder_component = name_registry.unique_dir_name(
            &parent,
            normalize_component(&current_folder.attributes.name),
            &current_folder.id,
        );
        let folder_path = parent.join(folder_component);
        ensure_directory(&folder_path, args.dry_run).await?;

        let file_refs = client.list_file_refs(&current_folder.id).await?;
        for file_ref in file_refs {
            sync_file_ref(
                ctx,
                client,
                file_ref,
                &folder_path,
                download_root,
                name_registry,
                args,
                since,
                remote_files,
                stats,
            )
            .await?;
        }

        let subfolders = client.list_subfolders(&current_folder.id).await?;
        for subfolder in subfolders {
            stack.push((subfolder, folder_path.clone()));
        }
    }

    Ok(())
}

#[allow(clippy::too_many_arguments)]
async fn sync_file_ref(
    ctx: &mut CommandContext,
    client: &StudipClient,
    file_ref: FileRef,
    folder_path: &Path,
    download_root: &Path,
    name_registry: &mut NameRegistry,
    args: &SyncArgs,
    since: Option<OffsetDateTime>,
    remote_files: &mut HashSet<PathBuf>,
    stats: &mut SyncStats,
) -> Result<()> {
    let file_name = name_registry.unique_file_name(
        folder_path,
        normalize_file_name(&file_ref.attributes.name),
        &file_ref.id,
    );
    let local_path = folder_path.join(&file_name);
    let relative_path = local_path
        .strip_prefix(download_root)
        .context("File path escaped download root")?
        .to_path_buf();

    remote_files.insert(relative_path.clone());

    if let Some(threshold) = since
        && let Some(remote_modified) = parse_remote_timestamp(&file_ref.attributes.modified)
        && remote_modified < threshold
    {
        if args.dry_run {
            println!(
                "Would skip {} (before --since threshold)",
                relative_path.display()
            );
        }
        stats.skipped_since += 1;
        return Ok(());
    }

    let needs_download = file_needs_download(ctx, &file_ref, &local_path);

    if args.dry_run {
        if needs_download {
            println!("Would download {}", relative_path.display());
            stats.planned += 1;
        } else {
            stats.skipped += 1;
        }
        return Ok(());
    }

    if needs_download {
        let checksum = download_file_to(client, &file_ref, &local_path).await?;
        update_file_state(ctx, &file_ref, &local_path, Some(checksum))?;
        println!("Downloaded {}", relative_path.display());
        stats.downloaded += 1;
    } else {
        stats.skipped += 1;
    }

    Ok(())
}

fn file_needs_download(ctx: &CommandContext, file_ref: &FileRef, local_path: &Path) -> bool {
    if !local_path.exists() {
        return true;
    }
    let existing = ctx
        .state_profile()
        .and_then(|profile| profile.files.get(&file_ref.id));

    if let Some(state) = existing {
        if let Some(saved) = state.remote_modified.as_deref()
            && saved != file_ref.attributes.modified.as_str()
        {
            return true;
        }

        if let (Some(saved_size), Some(remote_size)) = (state.size, file_ref.attributes.file_size)
            && saved_size != remote_size
        {
            return true;
        }

        if let Some(expected_checksum) = state.checksum.as_deref() {
            match compute_file_checksum(local_path) {
                Ok(actual) => {
                    if actual != expected_checksum {
                        return true;
                    }
                }
                Err(_) => return true,
            }
        }

        false
    } else {
        true
    }
}

async fn download_file_to(
    client: &StudipClient,
    file_ref: &FileRef,
    destination: &Path,
) -> Result<String> {
    let meta = file_ref
        .meta
        .as_ref()
        .ok_or_else(|| anyhow!("file-ref {} is missing download metadata", file_ref.id))?;

    let mut response = client.download_file(&meta.download_url).await?;

    if let Some(parent) = destination.parent() {
        fs::create_dir_all(parent).await?;
    }

    let tmp_path = destination.with_extension("part");
    let _ = fs::remove_file(&tmp_path).await;
    let mut file = fs::File::create(&tmp_path).await?;
    let mut hasher = Sha256::new();

    while let Some(chunk) = response.chunk().await? {
        hasher.update(&chunk);
        file.write_all(&chunk).await?;
    }
    file.flush().await?;
    drop(file);

    fs::rename(&tmp_path, destination).await?;
    let digest = hasher.finalize();
    Ok(hex_encode(digest.as_slice()))
}

fn update_file_state(
    ctx: &mut CommandContext,
    file_ref: &FileRef,
    destination: &Path,
    checksum: Option<String>,
) -> Result<()> {
    let profile = ctx.state_profile_mut();
    let entry =
        profile
            .files
            .entry(file_ref.id.clone())
            .or_insert_with(|| crate::state::FileState {
                id: file_ref.id.clone(),
                ..Default::default()
            });

    entry.size = file_ref.attributes.file_size;
    entry.remote_modified = Some(file_ref.attributes.modified.clone());
    entry.last_downloaded = Some(current_timestamp()?);
    entry.path_hint = Some(destination.to_path_buf());
    if let Some(hash) = checksum {
        entry.checksum = Some(hash);
    }

    ctx.mark_state_dirty();
    Ok(())
}

fn current_timestamp() -> Result<String> {
    let now = OffsetDateTime::now_utc();
    Ok(now.format(&Rfc3339)?)
}

async fn ensure_directory(path: &Path, dry_run: bool) -> Result<()> {
    if dry_run {
        return Ok(());
    }
    fs::create_dir_all(path).await?;
    Ok(())
}

fn normalize_component(input: &str) -> String {
    let mut sanitized = String::new();
    let mut last_was_separator = false;

    for ch in input.nfkd() {
        if is_combining_mark(ch) {
            continue;
        }

        if let Some(mapped) = map_special_letters(ch) {
            sanitized.push_str(mapped);
            last_was_separator = false;
            continue;
        }

        if ch.is_alphanumeric() {
            sanitized.push(ch);
            last_was_separator = false;
        } else if ch.is_whitespace() || matches!(ch, '-' | '_' | '.') {
            push_separator(&mut sanitized, &mut last_was_separator);
        } else {
            push_separator(&mut sanitized, &mut last_was_separator);
        }
    }

    let trimmed = sanitized.trim_matches('_');
    if trimmed.is_empty() {
        "untitled".to_string()
    } else {
        trimmed.to_string()
    }
}

fn normalize_file_name(name: &str) -> String {
    let mut sanitized = String::new();

    for ch in name.nfkd() {
        if is_combining_mark(ch) {
            continue;
        }

        match ch {
            '/' | '\\' => sanitized.push('_'),
            ch if ch.is_control() => continue,
            _ => {
                if let Some(mapped) = map_special_letters(ch) {
                    sanitized.push_str(mapped);
                } else {
                    sanitized.push(ch);
                }
            }
        }
    }

    if sanitized.trim().is_empty() {
        "file".to_string()
    } else {
        sanitized
    }
}

fn push_separator(buf: &mut String, last_was_separator: &mut bool) {
    if !*last_was_separator {
        buf.push('_');
        *last_was_separator = true;
    }
}

fn map_special_letters(ch: char) -> Option<&'static str> {
    match ch {
        'ß' => Some("ss"),
        'ẞ' => Some("SS"),
        'ä' => Some("ae"),
        'Ä' => Some("Ae"),
        'ö' => Some("oe"),
        'Ö' => Some("Oe"),
        'ü' => Some("ue"),
        'Ü' => Some("Ue"),
        'Æ' => Some("AE"),
        'æ' => Some("ae"),
        'Œ' => Some("OE"),
        'œ' => Some("oe"),
        'Ð' => Some("D"),
        'ð' => Some("d"),
        'Þ' => Some("Th"),
        'þ' => Some("th"),
        'Ł' => Some("L"),
        'ł' => Some("l"),
        _ => None,
    }
}

fn compute_file_checksum(path: &Path) -> Result<String> {
    let file = File::open(path)?;
    let mut reader = BufReader::new(file);
    let mut hasher = Sha256::new();
    let mut buffer = [0u8; 8192];

    loop {
        let read = reader.read(&mut buffer)?;
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
    }

    Ok(hex_encode(hasher.finalize().as_slice()))
}

fn prune_local(
    root: &Path,
    expected_files: &HashSet<PathBuf>,
    dry_run: bool,
) -> Result<PruneStats> {
    if !root.exists() {
        return Ok(PruneStats::default());
    }

    let mut stats = PruneStats::default();

    for entry in WalkDir::new(root)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
    {
        let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
        if !expected_files.contains(rel) {
            if dry_run {
                println!("Would delete {}", rel.display());
            } else {
                std::fs::remove_file(entry.path())?;
                println!("Deleted {}", rel.display());
            }
            stats.removed_files += 1;
        }
    }

    for entry in WalkDir::new(root)
        .contents_first(true)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_dir())
    {
        if entry.path() == root {
            continue;
        }
        if entry
            .path()
            .read_dir()
            .map(|mut i| i.next().is_none())
            .unwrap_or(false)
        {
            if dry_run {
                println!(
                    "Would remove empty directory {}",
                    entry
                        .path()
                        .strip_prefix(root)
                        .unwrap_or(entry.path())
                        .display()
                );
            } else {
                std::fs::remove_dir(entry.path()).ok();
            }
            stats.removed_dirs += 1;
        }
    }

    Ok(stats)
}

#[derive(Default)]
struct NameRegistry {
    dir_names: HashMap<PathBuf, HashSet<String>>,
    file_names: HashMap<PathBuf, HashSet<String>>,
}

impl NameRegistry {
    fn unique_dir_name(&mut self, parent: &Path, base: String, fallback_id: &str) -> String {
        Self::unique_name_internal(&mut self.dir_names, parent, base, fallback_id, "untitled")
    }

    fn unique_file_name(&mut self, parent: &Path, base: String, fallback_id: &str) -> String {
        Self::unique_name_internal(&mut self.file_names, parent, base, fallback_id, "file")
    }

    fn unique_name_internal(
        registry: &mut HashMap<PathBuf, HashSet<String>>,
        parent: &Path,
        base: String,
        fallback_id: &str,
        fallback_default: &str,
    ) -> String {
        let parent_key = parent.to_path_buf();
        let entry = registry.entry(parent_key).or_default();
        let short = short_id(fallback_id);
        let candidate = if base.is_empty() {
            format!("{fallback_default}_{short}")
        } else {
            base
        };

        if !entry.contains(&candidate) && !parent.join(&candidate).exists() {
            entry.insert(candidate.clone());
            return candidate;
        }

        let mut counter = 2;
        let base_name = candidate.clone();
        loop {
            let new_candidate = format!("{base_name}_{short}_{counter}");
            if !entry.contains(&new_candidate) && !parent.join(&new_candidate).exists() {
                entry.insert(new_candidate.clone());
                return new_candidate;
            }
            counter += 1;
        }
    }
}

fn short_id(id: &str) -> String {
    let trimmed = id.trim();
    if trimmed.len() <= 8 {
        trimmed.to_string()
    } else {
        trimmed[..8].to_string()
    }
}

fn hex_encode(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        let _ = write!(&mut out, "{:02x}", byte);
    }
    out
}

fn resolve_since_filter(ctx: &CommandContext, raw: Option<&str>) -> Result<Option<OffsetDateTime>> {
    let Some(value) = raw.map(|s| s.trim()).filter(|s| !s.is_empty()) else {
        return Ok(None);
    };

    if let Some(ts) = resolve_semester_since(ctx, value)? {
        return Ok(Some(ts));
    }

    if let Some(ts) = parse_compact_since(value)? {
        return Ok(Some(ts));
    }

    if let Ok(ts) = OffsetDateTime::parse(value, &Rfc3339) {
        return Ok(Some(ts));
    }

    bail!(
        "Unable to interpret --since value `{}`. Use a semester key (e.g., ws2526) or a date in DDMMYY / DDMMYYYY / RFC3339 format.",
        value
    );
}

fn resolve_semester_since(ctx: &CommandContext, key: &str) -> Result<Option<OffsetDateTime>> {
    let normalized = key.trim().to_ascii_lowercase();
    let semester = ctx
        .semester_by_key(&normalized)
        .or_else(|| ctx.semester_by_key(key));

    let Some(semester) = semester else {
        return Ok(None);
    };

    let start = semester.start.as_deref().ok_or_else(|| {
        anyhow!(
            "Semester `{}` is known but does not expose a start timestamp yet. Re-run `studip-sync list-courses --refresh` to refresh state.",
            key
        )
    })?;

    let parsed = OffsetDateTime::parse(start, &Rfc3339).with_context(|| {
        format!(
            "Failed to parse start timestamp `{}` for semester `{}`",
            start, key
        )
    })?;
    Ok(Some(parsed))
}

fn parse_compact_since(input: &str) -> Result<Option<OffsetDateTime>> {
    match input.len() {
        6 => {
            let fmt = format_description!("[day][month][year repr:last_two]");
            let date = Date::parse(input, &fmt)
                .with_context(|| format!("Failed to parse `{}` as DDMMYY date", input))?;
            let dt = PrimitiveDateTime::new(date, Time::MIDNIGHT);
            Ok(Some(dt.assume_offset(UtcOffset::UTC)))
        }
        8 => {
            let fmt = format_description!("[day][month][year]");
            let date = Date::parse(input, &fmt)
                .with_context(|| format!("Failed to parse `{}` as DDMMYYYY date", input))?;
            let dt = PrimitiveDateTime::new(date, Time::MIDNIGHT);
            Ok(Some(dt.assume_offset(UtcOffset::UTC)))
        }
        _ => Ok(None),
    }
}

fn parse_remote_timestamp(raw: &str) -> Option<OffsetDateTime> {
    if let Ok(ts) = OffsetDateTime::parse(raw, &Rfc3339) {
        return Some(ts);
    }
    if let Ok(epoch) = raw.parse::<i64>() {
        return OffsetDateTime::from_unix_timestamp(epoch).ok();
    }
    None
}

fn render_default_config_template(download_root: &Path) -> String {
    let download_root = download_root.to_string_lossy();
    format!(
        r#"# studip-sync configuration
# Run `studip-sync auth` to set credentials for the active profile.
default_profile = "default"

[profiles.default]
# Optional convenience field for display purposes only.
# username = "uni-id"
base_url = "https://studip.uni-trier.de"
jsonapi_path = "/jsonapi.php/v1"
download_root = "{download_root}"
max_concurrent_downloads = 3
"#
    )
}
