use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use tracing::{debug, info, info_span, trace, warn};

use crate::error::{Error, Result};
use crate::hash::{hash_index1_key, hash_index2};
use crate::header::{DatLocation, Platform};
use crate::index::IndexEntry;
use crate::index2::Index2Entry;
use crate::route::ArchiveTriple;
use crate::tracing_schema::span_names;
use crate::vfs::{StdFs, Vfs, VfsWrite};
use crate::write::atomic::replace_atomically;
use crate::write::index_writer::pack_index_data;
use crate::write::index2_writer::pack_index2_data;
use crate::write::payload::encode_standard;
use crate::write::{APPEND_MAX_FILE_SIZE, DAT_MAX_SIZE};

/// Append-only writer for existing `SqPack` archives.
///
/// Stages one or more files with [`append`](SqPackAppender::append) — each call writes the
/// DEFLATE-encoded payload to `.dat0` immediately — then commits all pending index updates
/// atomically with [`commit`](SqPackAppender::commit). Collisions against both the on-disk
/// index and any other staged (but not yet committed) paths are detected before the first
/// byte is written.
///
/// Use [`open`](SqPackAppender::open) for real-filesystem access or
/// [`open_with_vfs`](SqPackAppender::open_with_vfs) to inject a [`Vfs`](crate::vfs::Vfs)
/// for testing.
pub struct SqPackAppender {
    install_root: PathBuf,
    platform: Platform,
    read_vfs: Arc<dyn Vfs>,
    write_vfs: Arc<dyn VfsWrite>,
    staging: BTreeMap<ArchiveTriple, ArchiveStaging>,
}

impl std::fmt::Debug for SqPackAppender {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SqPackAppender")
            .field("install_root", &self.install_root)
            .field("platform", &self.platform)
            .field("staged_archives", &self.staging.len())
            .finish_non_exhaustive()
    }
}

#[derive(Debug, Default, Clone)]
struct ArchiveStaging {
    v1: Vec<PendingEntry>,
    v2: Vec<PendingEntry>,
}

#[derive(Debug, Clone)]
struct PendingEntry {
    game_path: String,
    hash: u64,
    location: DatLocation,
}

/// Outcome returned by a successful [`SqPackAppender::append`] call.
///
/// `#[non_exhaustive]` — new fields may be added in future minor releases.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppendOutcome {
    /// Absolute byte offset within the `.dat` file where the payload was written.
    pub byte_offset: u64,
    /// `.dat` file index that received the payload; always `0` for the current implementation.
    pub data_file_id: u8,
    /// Total bytes written to the `.dat` file, including any alignment padding prepended.
    pub bytes_written: u64,
    /// Number of DEFLATE blocks in the encoded payload.
    pub block_count: u32,
}

impl SqPackAppender {
    /// Opens an appender backed by the real filesystem at `install_root`.
    pub fn open(install_root: impl Into<PathBuf>, platform: Platform) -> Result<Self> {
        let install_root = install_root.into();
        let read_vfs: Arc<dyn Vfs> = Arc::new(StdFs::new());
        let write_vfs: Arc<dyn VfsWrite> = Arc::new(StdFs::new());
        Self::open_with_vfs(install_root, platform, read_vfs, write_vfs)
    }

    /// Opens an appender with injected [`Vfs`] implementations for reads and writes.
    ///
    /// Useful in tests where an [`InMemoryFs`](crate::vfs::InMemoryFs) replaces the
    /// real filesystem. Returns an error if `platform` is
    /// [`Platform::Unknown`](crate::header::Platform::Unknown) or if `install_root` does
    /// not exist in `read_vfs`.
    pub fn open_with_vfs(
        install_root: impl Into<PathBuf>,
        platform: Platform,
        read_vfs: Arc<dyn Vfs>,
        write_vfs: Arc<dyn VfsWrite>,
    ) -> Result<Self> {
        let install_root = install_root.into();
        if matches!(platform, Platform::Unknown(_)) {
            return Err(Error::UnsupportedPlatform { platform });
        }
        if !read_vfs.exists(&install_root) {
            return Err(Error::io_at(
                &install_root,
                std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    "SqPack install root does not exist",
                ),
            ));
        }
        Ok(Self {
            install_root,
            platform,
            read_vfs,
            write_vfs,
            staging: BTreeMap::new(),
        })
    }

    /// Encodes and writes a file to the appropriate `.dat` archive, staging its index entries.
    ///
    /// The payload is DEFLATE-compressed and written to `.dat0` immediately; the index entries
    /// are held in memory until [`commit`](SqPackAppender::commit) is called. Returns an error
    /// if the path already exists in any on-disk or in-flight index, if the file exceeds
    /// [`APPEND_MAX_FILE_SIZE`](crate::write::APPEND_MAX_FILE_SIZE), or if the resulting
    /// `.dat` would exceed [`DAT_MAX_SIZE`](crate::write::DAT_MAX_SIZE).
    #[allow(clippy::too_many_lines)]
    pub fn append(&mut self, game_path: &str, bytes: &[u8]) -> Result<AppendOutcome> {
        let span = info_span!(span_names::SQPACK_APPEND, path = %game_path);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        if bytes.len() as u64 > APPEND_MAX_FILE_SIZE {
            return Err(Error::AppendFileTooLarge {
                size: bytes.len() as u64,
                max: APPEND_MAX_FILE_SIZE,
            });
        }

        let triple = ArchiveTriple::from_game_path(game_path).ok_or_else(|| {
            let name = game_path
                .split(['/', '\\'])
                .next()
                .unwrap_or("")
                .to_ascii_lowercase();
            Error::UnknownCategory { name }
        })?;

        let dat_path = triple
            .dat_path(&self.install_root, self.platform, 0)
            .ok_or(Error::UnsupportedPlatform {
                platform: self.platform,
            })?;
        let index_path = triple.index_path(&self.install_root, self.platform);
        let index2_path = triple.index2_path(&self.install_root, self.platform);

        let v1_hash = hash_index1_key(game_path);
        let v2_hash = hash_index2(game_path);

        let target_v1 = index_path.as_ref().is_some_and(|p| self.read_vfs.exists(p));
        let target_v2 = index2_path
            .as_ref()
            .is_some_and(|p| self.read_vfs.exists(p));
        if !target_v1 && !target_v2 {
            return Err(Error::Io {
                path: Some(self.install_root.clone()),
                source: std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    format!(
                        "no .index or .index2 for routed archive {}",
                        triple.file_stem()
                    ),
                ),
            });
        }

        if target_v1 {
            let Some(path) = index_path.as_ref() else {
                unreachable!("target_v1 implies index_path is Some")
            };
            self.check_no_v1_collision(path, v1_hash, game_path)?;
        }
        if target_v2 {
            let Some(path) = index2_path.as_ref() else {
                unreachable!("target_v2 implies index2_path is Some")
            };
            self.check_no_v2_collision(path, v2_hash, game_path)?;
        }

        let entry = self.staging.entry(triple).or_default();
        if target_v1 && entry.v1.iter().any(|e| e.hash == v1_hash) {
            return Err(Error::FileAlreadyExists {
                game_path: game_path.to_string(),
            });
        }
        if target_v2 && entry.v2.iter().any(|e| e.hash == u64::from(v2_hash)) {
            return Err(Error::FileAlreadyExists {
                game_path: game_path.to_string(),
            });
        }

        let encoded = encode_standard(bytes)?;
        let bytes_written = encoded.bytes.len() as u64;

        let mut dat_handle = self
            .write_vfs
            .open_append(&dat_path)
            .map_err(|e| Error::io_at(&dat_path, e))?;
        let current_eof = dat_handle
            .seek(std::io::SeekFrom::End(0))
            .map_err(|e| Error::io_at(&dat_path, e))?;

        let aligned_offset = align_dat_offset(current_eof);
        let pad_bytes = aligned_offset - current_eof;
        if aligned_offset.saturating_add(bytes_written) > DAT_MAX_SIZE {
            return Err(Error::DatSizeOverflow {
                current_size: aligned_offset,
                additional_bytes: bytes_written,
                max: DAT_MAX_SIZE,
            });
        }

        if pad_bytes != 0 {
            let pad_usize = usize::try_from(pad_bytes).map_err(|_| Error::InvalidBlockLayout {
                context: "append.eof_pad",
                message: format!("pad {pad_bytes} exceeds usize::MAX"),
            })?;
            let zeros = vec![0u8; pad_usize];
            dat_handle
                .write_all(&zeros)
                .map_err(|e| Error::io_at(&dat_path, e))?;
        }

        dat_handle
            .write_all(&encoded.bytes)
            .map_err(|e| Error::io_at(&dat_path, e))?;
        dat_handle
            .sync_all()
            .map_err(|e| Error::io_at(&dat_path, e))?;
        drop(dat_handle);

        let total_bytes_written = bytes_written + pad_bytes;
        trace!(
            byte_offset = aligned_offset,
            bytes_written = total_bytes_written,
            block_count = encoded.block_count,
            "wrote dat payload"
        );

        let location = DatLocation {
            data_file_id: 0,
            byte_offset: aligned_offset,
        };

        if target_v1 {
            let _data = pack_index_data(false, location)?;
            entry.v1.push(PendingEntry {
                game_path: game_path.to_string(),
                hash: v1_hash,
                location,
            });
        }
        if target_v2 {
            let _data = pack_index2_data(location)?;
            entry.v2.push(PendingEntry {
                game_path: game_path.to_string(),
                hash: u64::from(v2_hash),
                location,
            });
        }

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            path = %game_path,
            bytes_in = bytes.len(),
            bytes_written = total_bytes_written,
            byte_offset = aligned_offset,
            block_count = encoded.block_count,
            elapsed_ms,
            "sqpack.append"
        );

        Ok(AppendOutcome {
            byte_offset: aligned_offset,
            data_file_id: 0,
            bytes_written: total_bytes_written,
            block_count: encoded.block_count,
        })
    }

    /// Atomically writes all staged index entries to their respective `.index` and `.index2` files.
    ///
    /// Each index file is read, the pending entries are inserted (sorted by hash), and the
    /// result is written via [`replace_atomically`](crate::write::atomic::replace_atomically)
    /// with freshly computed SHA-1 checksums. Clears the staging buffer on success. Logs a
    /// warning if called with nothing staged.
    pub fn commit(&mut self) -> Result<()> {
        let span = info_span!(span_names::SQPACK_APPEND_COMMIT);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let archive_count = self.staging.len();
        let mut indexes_touched = 0usize;
        let mut appended_files: BTreeSet<(ArchiveTriple, String)> = BTreeSet::new();

        let triples: Vec<ArchiveTriple> = self.staging.keys().copied().collect();
        for triple in triples {
            let staging = match self.staging.get(&triple) {
                Some(s) => s.clone(),
                None => continue,
            };

            if !staging.v1.is_empty() {
                let path = triple.index_path(&self.install_root, self.platform).ok_or(
                    Error::UnsupportedPlatform {
                        platform: self.platform,
                    },
                )?;
                debug!(path = %path.display(), pending = staging.v1.len(), "rebuilding v1 index");
                let mut parsed =
                    crate::write::index_writer::read_index_file(&*self.read_vfs, &path)?;
                for pending in &staging.v1 {
                    let data = pack_index_data(false, pending.location)?;
                    if parsed.entries.iter().any(|e| e.hash == pending.hash) {
                        return Err(Error::FileAlreadyExists {
                            game_path: pending.game_path.clone(),
                        });
                    }
                    parsed.entries.push(IndexEntry {
                        hash: pending.hash,
                        data,
                        padding: 0,
                    });
                    appended_files.insert((triple, pending.game_path.clone()));
                }
                parsed.entries.sort_by_key(|e| e.hash);
                let serialised = crate::write::index_writer::serialise_via_insert(&parsed)?;
                replace_atomically(&*self.write_vfs, &path, &serialised)?;
                indexes_touched += 1;
            }

            if !staging.v2.is_empty() {
                let path = triple
                    .index2_path(&self.install_root, self.platform)
                    .ok_or(Error::UnsupportedPlatform {
                        platform: self.platform,
                    })?;
                debug!(path = %path.display(), pending = staging.v2.len(), "rebuilding v2 index");
                let mut parsed =
                    crate::write::index2_writer::read_index2_file(&*self.read_vfs, &path)?;
                for pending in &staging.v2 {
                    let data = pack_index2_data(pending.location)?;
                    if parsed
                        .entries
                        .iter()
                        .any(|e| u64::from(e.hash) == pending.hash)
                    {
                        return Err(Error::FileAlreadyExists {
                            game_path: pending.game_path.clone(),
                        });
                    }
                    #[allow(clippy::cast_possible_truncation)]
                    let hash_u32 = pending.hash as u32;
                    parsed.entries.push(Index2Entry {
                        hash: hash_u32,
                        data,
                    });
                    appended_files.insert((triple, pending.game_path.clone()));
                }
                parsed.entries.sort_by_key(|e| e.hash);
                let serialised = crate::write::index2_writer::serialise_via_insert(&parsed)?;
                replace_atomically(&*self.write_vfs, &path, &serialised)?;
                indexes_touched += 1;
            }

            self.staging.remove(&triple);
        }

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            appended_files = appended_files.len(),
            dats_touched = archive_count,
            indexes_touched,
            elapsed_ms,
            "sqpack.append.commit"
        );
        if archive_count == 0 {
            warn!("commit() called with empty staging buffer; nothing to do");
        }
        Ok(())
    }

    /// Convenience wrapper that opens, appends a single file, and commits in one call.
    pub fn append_one(
        install_root: impl Into<PathBuf>,
        platform: Platform,
        game_path: &str,
        bytes: &[u8],
    ) -> Result<AppendOutcome> {
        let mut writer = Self::open(install_root, platform)?;
        let outcome = writer.append(game_path, bytes)?;
        writer.commit()?;
        Ok(outcome)
    }

    /// Returns the number of unique game-path entries currently staged but not yet committed.
    #[must_use]
    pub fn staged_count(&self) -> usize {
        let mut seen: BTreeSet<(ArchiveTriple, &str)> = BTreeSet::new();
        for (triple, staging) in &self.staging {
            for pending in &staging.v1 {
                seen.insert((*triple, pending.game_path.as_str()));
            }
            for pending in &staging.v2 {
                seen.insert((*triple, pending.game_path.as_str()));
            }
        }
        seen.len()
    }

    fn check_no_v1_collision(&self, path: &Path, hash: u64, game_path: &str) -> Result<()> {
        let idx = crate::index::Index::open_in(path, &*self.read_vfs)?;
        if idx.get(hash).is_some() {
            return Err(Error::FileAlreadyExists {
                game_path: game_path.to_string(),
            });
        }
        Ok(())
    }

    fn check_no_v2_collision(&self, path: &Path, hash: u32, game_path: &str) -> Result<()> {
        let idx = crate::index2::Index2::open_in(path, &*self.read_vfs)?;
        if idx.get(hash).is_some() {
            return Err(Error::FileAlreadyExists {
                game_path: game_path.to_string(),
            });
        }
        Ok(())
    }
}

fn align_dat_offset(offset: u64) -> u64 {
    const ALIGN: u64 = 128;
    let rem = offset % ALIGN;
    if rem == 0 {
        offset
    } else {
        offset + (ALIGN - rem)
    }
}
