// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

use anyhow::{Context, Result, anyhow, bail};
use std::fs;
use std::path::Path;
use syn::{
    Expr, ExprLit, Field, GenericArgument, Item, ItemStruct, Lit, Meta, PathArguments, Type,
    TypePath,
};

/// The Python type an operator option field maps to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionType {
    /// `Option<String>`
    Str,
    /// `Option<usize>`
    Int,
    /// `Option<bool>`
    Bool,
    /// `Option<jiff::Timestamp>`
    DateTime,
    /// `Option<HashMap<String, String>>`
    Dict,
}

/// One keyword option of an operator method.
#[derive(Debug, Clone)]
pub struct OptionField {
    /// The option name, e.g. `if_match`.
    pub name: String,
    /// The value type of this option.
    pub ty: OptionType,
    /// The doc comment of the field, trimmed.
    pub doc: String,
}

/// One option type, parsed from a `#[pyclass]` struct in
/// `bindings/python/src/options.rs`, e.g. `ReadOptions`.
#[derive(Debug, Clone)]
pub struct Options {
    /// The struct name, e.g. `ReadOptions`.
    pub name: String,
    /// The doc comment of the struct, trimmed.
    pub doc: String,
    /// The fields of the struct.
    pub fields: Vec<OptionField>,
}

/// Parse all `#[pyclass]` option structs from `bindings/python/src/options.rs`.
///
/// A struct is treated as an option type when it is annotated with
/// `#[pyclass]` and its name ends with `Options`.
pub fn parse(path: &Path) -> Result<Vec<Options>> {
    let content =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    let ast = syn::parse_file(&content)?;

    let mut options = Vec::new();
    for item in ast.items {
        if let Item::Struct(struct_) = item
            && is_option_struct(&struct_)
        {
            options.push(parse_struct(&struct_)?);
        }
    }
    Ok(options)
}

/// Whether a struct maps to a generated `*OptionsKwargs` TypedDict.
fn is_option_struct(struct_: &ItemStruct) -> bool {
    struct_.ident.to_string().ends_with("Options")
        && struct_
            .attrs
            .iter()
            .any(|attr| attr.path().is_ident("pyclass"))
}

fn parse_struct(struct_: &ItemStruct) -> Result<Options> {
    let name = struct_.ident.to_string();
    let doc = doc_lines(&struct_.attrs).join("\n").trim().to_string();

    let fields = match &struct_.fields {
        syn::Fields::Named(named) => &named.named,
        _ => bail!("option struct {name} must have named fields"),
    };

    let mut parsed = Vec::with_capacity(fields.len());
    for field in fields {
        parsed.push(parse_field(field)?);
    }

    Ok(Options {
        name,
        doc,
        fields: parsed,
    })
}

fn parse_field(field: &Field) -> Result<OptionField> {
    let name = field
        .ident
        .as_ref()
        .ok_or_else(|| anyhow!("option field is unnamed: {field:?}"))?
        .to_string();
    let ty = parse_type(&field.ty)?;
    let doc = doc_lines(&field.attrs).join("\n").trim().to_string();

    Ok(OptionField { name, ty, doc })
}

/// Parse the type of an option field, which must be `Option<T>`.
fn parse_type(ty: &Type) -> Result<OptionType> {
    let Type::Path(TypePath { path, .. }) = ty else {
        bail!("unsupported option field type: {ty:?}");
    };
    let segment = path
        .segments
        .last()
        .ok_or_else(|| anyhow!("option type path is empty: {ty:?}"))?;
    if segment.ident != "Option" {
        bail!("option field must be Option<T>, found {segment:?}");
    }
    let PathArguments::AngleBracketed(args) = &segment.arguments else {
        bail!("Option must have angle bracketed arguments");
    };
    let Some(GenericArgument::Type(Type::Path(inner))) = args.args.first() else {
        bail!("Option must contain a type argument");
    };
    let inner_segment = inner
        .path
        .segments
        .last()
        .ok_or_else(|| anyhow!("option inner type path is empty: {inner:?}"))?;

    Ok(match inner_segment.ident.to_string().as_str() {
        "String" => OptionType::Str,
        "usize" => OptionType::Int,
        "bool" => OptionType::Bool,
        // jiff::Timestamp
        "Timestamp" => OptionType::DateTime,
        // HashMap<String, String>
        "HashMap" => OptionType::Dict,
        other => bail!("unsupported option type: {other}"),
    })
}

/// Collect the doc comment of a struct or field as trimmed lines.
fn doc_lines(attrs: &[syn::Attribute]) -> Vec<String> {
    attrs
        .iter()
        .filter(|attr| attr.path().is_ident("doc"))
        .filter_map(|attr| {
            if let Meta::NameValue(meta) = &attr.meta
                && let Expr::Lit(ExprLit {
                    lit: Lit::Str(s), ..
                }) = &meta.value
            {
                return Some(s.value().trim().to_string());
            }

            None
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_options() {
        let path = crate::workspace_dir().join("bindings/python/src/options.rs");
        let options = parse(&path).unwrap();

        let names: Vec<&str> = options.iter().map(|o| o.name.as_str()).collect();
        assert_eq!(
            names,
            [
                "ReadOptions",
                "WriteOptions",
                "ListOptions",
                "StatOptions",
                "DeleteOptions"
            ]
        );

        let read = &options[0];
        assert_eq!(read.doc, "Options for `read` operations.");
        assert_eq!(read.fields.len(), 14);
        assert_eq!(read.fields[0].name, "version");
        assert_eq!(read.fields[0].ty, OptionType::Str);
        assert_eq!(read.fields[4].name, "offset");
        assert_eq!(read.fields[4].ty, OptionType::Int);
        assert_eq!(read.fields[9].name, "if_modified_since");
        assert_eq!(read.fields[9].ty, OptionType::DateTime);
        assert_eq!(read.fields[0].doc, "The version of the file.");

        let write = &options[1];
        assert_eq!(write.fields.len(), 11);
        assert_eq!(write.fields[10].name, "user_metadata");
        assert_eq!(write.fields[10].ty, OptionType::Dict);
        assert_eq!(
            write.fields[10].doc,
            "The user metadata to set on the file."
        );

        let delete = &options[4];
        assert_eq!(delete.fields.len(), 2);
        assert_eq!(
            delete.fields[1].doc,
            "If True, delete the path recursively.\n\nOnly supported on backends that support recursive delete."
        );
    }
}
