use core::fmt::Write as _;
use std::io;
use std::path::{Path, PathBuf};

use bone_document::{
    Document, ExtrudeError, FeatureNode, LabelSidecar, evaluate_extrude, evaluate_sketch,
};
use bone_kernel::{BrepError, BrepSolid};
use bone_types::{
    CancelFlag, DocumentId, FeatureId, StepEntityKind, StepFileHeader, StepFileName,
    StepOrganization, StepOriginatingSystem, StepSchema,
};

const PINNED_TIMESTAMP: &str = "1970-01-01T00:00:00";
const ORIGINATING_SYSTEM: &str = concat!("Bone ", env!("CARGO_PKG_VERSION"));

const SUPPORTED_READ: &[StepSchema] = &[StepSchema::Ap214];

const fn schema_token(schema: StepSchema) -> &'static str {
    match schema {
        StepSchema::Ap214 => "AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }",
        StepSchema::Ap242E2 => {
            "AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 3 1 4 }"
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum HeaderDefect {
    NoHeaderSection,
    NoFileSchema,
    UnreadableFileSchema,
}

impl HeaderDefect {
    #[must_use]
    const fn label(self) -> &'static str {
        match self {
            Self::NoHeaderSection => "no HEADER section",
            Self::NoFileSchema => "no FILE_SCHEMA entry",
            Self::UnreadableFileSchema => "an unreadable FILE_SCHEMA token",
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum StepError {
    #[error("schema {0} is unsupported for export; only AP214 is emitted")]
    SchemaUnsupported(StepSchema),
    #[error("phase-2 step export writes a single body per document")]
    BodyCount { count: usize },
    #[error("extrude feature {feature:?} has no evaluable sketch")]
    DanglingExtrude { feature: FeatureId },
    #[error("feature {feature:?} failed to evaluate")]
    Evaluation {
        feature: FeatureId,
        #[source]
        source: ExtrudeError,
    },
    #[error("step geometry uses {kind}, which the reader does not yet bridge")]
    UnsupportedEntity { kind: StepEntityKind },
    #[error("step file declares {found}; the reader supports {supported:?}")]
    SchemaMismatch {
        found: StepSchema,
        supported: &'static [StepSchema],
    },
    #[error("step header carries {}", reason.label())]
    MalformedHeader { reason: HeaderDefect },
    #[error("step file carries no importable solid")]
    IncompleteFile,
    #[error("step operation canceled before it completed")]
    Canceled,
    #[error("step file carries {solids} solids; assemblies are not yet imported")]
    UnsupportedAssembly { solids: usize },
    #[error("io at {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    #[error("step geometry: {0}")]
    Geometry(#[from] BrepError),
    #[error("label sidecar at {path}: {source}")]
    Sidecar {
        path: PathBuf,
        #[source]
        source: Box<bone_document::RonError>,
    },
}

fn guard(cancel: CancelFlag) -> Result<(), StepError> {
    if cancel.is_canceled() {
        return Err(StepError::Canceled);
    }
    Ok(())
}

pub fn write(
    document: &Document,
    path: &Path,
    schema: StepSchema,
    cancel: CancelFlag,
) -> Result<(), StepError> {
    if schema != StepSchema::Ap214 {
        return Err(StepError::SchemaUnsupported(schema));
    }
    guard(cancel)?;
    let solid = body_of(document)?;
    let body = solid.to_step_body()?;
    guard(cancel)?;
    let contents = envelope(
        &render_header(&export_header(document.name(), schema)),
        &body,
    );
    let labels_path = sidecar_path(path);
    let sidecar = LabelSidecar::capture(&solid)
        .to_ron()
        .map_err(|source| StepError::Sidecar {
            path: labels_path.clone(),
            source: Box::new(source),
        })?;
    write_file(&labels_path, sidecar.as_bytes())?;
    commit_step(path, contents.as_bytes()).inspect_err(|_| {
        let _ = std::fs::remove_file(&labels_path);
    })
}

pub fn body_of(document: &Document) -> Result<BrepSolid, StepError> {
    let bodies: Vec<FeatureId> = document
        .feature_tree()
        .iter()
        .filter_map(|(feature, node)| {
            matches!(node, FeatureNode::Extrude(_) | FeatureNode::ImportedBody(_))
                .then_some(feature)
        })
        .collect();
    let [feature] = bodies.as_slice() else {
        return Err(StepError::BodyCount {
            count: bodies.len(),
        });
    };
    let feature = *feature;
    if let Some(solid) = document.imported_body_of_feature(feature) {
        return Ok(solid.clone());
    }
    let extrude = document
        .extrude_of_feature(feature)
        .ok_or(StepError::DanglingExtrude { feature })?;
    let sketch = document
        .sketch(extrude.sketch)
        .ok_or(StepError::DanglingExtrude { feature })?;
    evaluate_extrude(feature, &evaluate_sketch(sketch), extrude)
        .result()
        .clone()
        .map_err(|source| StepError::Evaluation { feature, source })
}

pub fn read(path: &Path, cancel: CancelFlag) -> Result<Document, StepError> {
    guard(cancel)?;
    let text = read_file(path, cancel)?;
    classify_schema(&text)?;
    let sidecar = read_sidecar(path)?;
    guard(cancel)?;
    let mut document = Document::new(DocumentId::default(), document_name(path));
    document.import_body(|feature| {
        BrepSolid::from_step(
            &text,
            feature,
            sidecar.as_ref().map(|side| (side.solid(), side.reattach())),
            cancel,
        )
        .map_err(read_geometry_error)
    })?;
    guard(cancel)?;
    Ok(document)
}

fn read_geometry_error(error: BrepError) -> StepError {
    match error {
        BrepError::Canceled => StepError::Canceled,
        BrepError::StepUnsupported { kind } => StepError::UnsupportedEntity { kind },
        BrepError::StepSyntax | BrepError::StepNoData | BrepError::StepEmpty => {
            StepError::IncompleteFile
        }
        BrepError::StepMultipleSolids { count } => StepError::UnsupportedAssembly { solids: count },
        other => StepError::Geometry(other),
    }
}

fn classify_schema(text: &str) -> Result<(), StepError> {
    let found: Vec<StepSchema> = file_schema_tokens(text)?
        .iter()
        .filter_map(|token| recognize_schema(token))
        .collect();
    if found.iter().any(|schema| SUPPORTED_READ.contains(schema)) {
        return Ok(());
    }
    match found.iter().find(|schema| !SUPPORTED_READ.contains(schema)) {
        Some(&found) => Err(StepError::SchemaMismatch {
            found,
            supported: SUPPORTED_READ,
        }),
        None => Ok(()),
    }
}

fn file_schema_tokens(text: &str) -> Result<Vec<String>, StepError> {
    let header = header_section(text)?;
    let at = find_in_code_ci(header, "FILE_SCHEMA").ok_or(StepError::MalformedHeader {
        reason: HeaderDefect::NoFileSchema,
    })?;
    let statement = &header[at..];
    let statement = statement
        .find(';')
        .map_or(statement, |end| &statement[..end]);
    let tokens: Vec<String> = quoted_segments(statement)
        .map(str::to_ascii_uppercase)
        .collect();
    if tokens.is_empty() {
        return Err(StepError::MalformedHeader {
            reason: HeaderDefect::UnreadableFileSchema,
        });
    }
    Ok(tokens)
}

fn header_section(text: &str) -> Result<&str, StepError> {
    let start = find_in_code_ci(text, "HEADER").ok_or(StepError::MalformedHeader {
        reason: HeaderDefect::NoHeaderSection,
    })?;
    let rest = &text[start..];
    let end = find_in_code_ci(rest, "ENDSEC").unwrap_or(rest.len());
    Ok(&rest[..end])
}

fn quoted_segments(statement: &str) -> impl Iterator<Item = &str> {
    statement.split('\'').skip(1).step_by(2)
}

#[derive(Copy, Clone)]
enum HeaderScan {
    Code,
    Quoted,
    Comment,
}

fn find_in_code_ci(haystack: &str, needle: &str) -> Option<usize> {
    let (hay, pat) = (haystack.as_bytes(), needle.as_bytes());
    if pat.is_empty() || hay.len() < pat.len() {
        return None;
    }
    hay.iter()
        .scan((HeaderScan::Code, 0u8), |(state, prev), &byte| {
            let in_code = matches!(state, HeaderScan::Code);
            *state = match *state {
                HeaderScan::Code if byte == b'\'' => HeaderScan::Quoted,
                HeaderScan::Code if *prev == b'/' && byte == b'*' => HeaderScan::Comment,
                HeaderScan::Quoted if byte == b'\'' => HeaderScan::Code,
                HeaderScan::Comment if *prev == b'*' && byte == b'/' => HeaderScan::Code,
                other => other,
            };
            *prev = byte;
            Some(in_code)
        })
        .enumerate()
        .take(hay.len() - pat.len() + 1)
        .find(|&(start, in_code)| {
            in_code && hay[start..start + pat.len()].eq_ignore_ascii_case(pat)
        })
        .map(|(start, _)| start)
}

fn recognize_schema(token: &str) -> Option<StepSchema> {
    if token.contains("AP242") || token.contains("MANAGED_MODEL_BASED") {
        Some(StepSchema::Ap242E2)
    } else if token.contains("AUTOMOTIVE_DESIGN") {
        Some(StepSchema::Ap214)
    } else {
        None
    }
}

fn document_name(path: &Path) -> String {
    path.file_stem().map_or_else(
        || "imported".to_owned(),
        |stem| stem.to_string_lossy().into_owned(),
    )
}

fn envelope(header: &str, body: &str) -> String {
    format!("ISO-10303-21;\n{header}DATA;\n{body}ENDSEC;\nEND-ISO-10303-21;\n")
}

fn export_header(file_name: &str, schema: StepSchema) -> StepFileHeader {
    StepFileHeader {
        schema,
        originating_system: StepOriginatingSystem::new(ORIGINATING_SYSTEM),
        organization: StepOrganization::new(""),
        file_name: StepFileName::new(file_name),
    }
}

fn render_header(header: &StepFileHeader) -> String {
    format!(
        "HEADER;\nFILE_DESCRIPTION(('Bone geometry'),'2;1');\nFILE_NAME('{name}','{PINNED_TIMESTAMP}',(''),('{organization}'),'','{system}','');\nFILE_SCHEMA(('{schema}'));\nENDSEC;\n",
        name = escape_step_string(header.file_name.as_str()),
        organization = escape_step_string(header.organization.as_str()),
        system = escape_step_string(header.originating_system.as_str()),
        schema = schema_token(header.schema),
    )
}

fn escape_step_string(value: &str) -> String {
    value.chars().map(escape_step_char).collect()
}

fn escape_step_char(ch: char) -> String {
    match ch {
        '\'' => "''".to_string(),
        '\\' => "\\\\".to_string(),
        c if c.is_ascii_graphic() || c == ' ' => c.to_string(),
        c => {
            let mut units = [0u16; 2];
            c.encode_utf16(&mut units)
                .iter()
                .fold(String::from("\\X2\\"), |mut acc, unit| {
                    let _ = write!(acc, "{unit:04X}");
                    acc
                })
                + "\\X0\\"
        }
    }
}

fn suffixed(path: &Path, suffix: &str) -> PathBuf {
    let mut name = path.as_os_str().to_os_string();
    name.push(suffix);
    PathBuf::from(name)
}

fn sidecar_path(step: &Path) -> PathBuf {
    suffixed(step, ".labels")
}

fn write_file(path: &Path, bytes: &[u8]) -> Result<(), StepError> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|source| StepError::Io {
            path: parent.to_path_buf(),
            source,
        })?;
    }
    std::fs::write(path, bytes).map_err(|source| StepError::Io {
        path: path.to_path_buf(),
        source,
    })
}

fn commit_step(path: &Path, bytes: &[u8]) -> Result<(), StepError> {
    let staging = suffixed(path, ".staging");
    write_file(&staging, bytes)
        .and_then(|()| {
            std::fs::rename(&staging, path).map_err(|source| StepError::Io {
                path: path.to_path_buf(),
                source,
            })
        })
        .inspect_err(|_| {
            let _ = std::fs::remove_file(&staging);
        })
}

const READ_CHUNK_BYTES: usize = 64 * 1024;

fn read_file(path: &Path, cancel: CancelFlag) -> Result<String, StepError> {
    use io::Read;
    let as_io = |source: io::Error| StepError::Io {
        path: path.to_path_buf(),
        source,
    };
    let file = std::fs::File::open(path).map_err(as_io)?;
    let mut reader = io::BufReader::new(file);
    let bytes = std::iter::from_fn(|| {
        if cancel.is_canceled() {
            return Some(Err(StepError::Canceled));
        }
        let mut chunk = vec![0u8; READ_CHUNK_BYTES];
        match reader.read(&mut chunk) {
            Ok(0) => None,
            Ok(n) => {
                chunk.truncate(n);
                Some(Ok(chunk))
            }
            Err(e) if e.kind() == io::ErrorKind::Interrupted => Some(Ok(Vec::new())),
            Err(e) => Some(Err(as_io(e))),
        }
    })
    .collect::<Result<Vec<Vec<u8>>, StepError>>()?
    .concat();
    String::from_utf8(bytes).map_err(|e| as_io(io::Error::new(io::ErrorKind::InvalidData, e)))
}

fn read_sidecar(step: &Path) -> Result<Option<LabelSidecar>, StepError> {
    let path = sidecar_path(step);
    match std::fs::read_to_string(&path) {
        Ok(text) => Ok(match LabelSidecar::from_ron(&text) {
            Ok(sidecar) => Some(sidecar),
            Err(source) => {
                tracing::warn!(
                    path = %path.display(),
                    error = %source,
                    "ignoring an unreadable step label sidecar"
                );
                None
            }
        }),
        Err(ref source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(source) => Err(StepError::Io { path, source }),
    }
}
