use crate::TypescriptEmitter;
use crate::brand::Brands;
use crate::naming::{enum_member, property_name};
use crate::options::{EnumStyle, NullStyle, Options, Output, TypeStyle};
use crate::typemap;
use convert_case::{Case, Casing};
use gieter_core::emitter::{EmitError, Emitter, EmitterOutput, GeneratedFile};
use gieter_core::ir::{Catalog, Column, ColumnType, Enum, ForeignKey};
use gieter_core::writer::Writer;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;

const HEADER: &str = "// Generated by gieter. Do not edit manually.\n\n";

/// Per-table context the column renderer needs: branding, foreign keys, and the
/// files involved (for cross-file brand imports). `None` for views.
struct TableContext<'a> {
    brands: &'a Brands,
    schema: &'a str,
    table: &'a str,
    primary_key: &'a [String],
    foreign_keys: &'a [ForeignKey],
    current_file: &'a str,
    brands_file: Option<&'a str>,
}

pub fn render(catalog: &Catalog, options: &Options) -> Result<EmitterOutput, EmitError> {
    let mut files: BTreeMap<String, FileContents> = BTreeMap::new();

    let brands = Brands::resolve(catalog, options);

    // Only claim a `brands` file when there are brands to put in it.
    let brands_file = if brands.is_empty() {
        None
    } else {
        Some(target_file(&options.output, "brands")?)
    };

    if let Some(brands_file) = brands_file {
        for (name, definition) in brands.declarations() {
            let import = definition.import.clone().into_iter().collect();
            push(
                &mut files,
                brands_file,
                render_brand(name, &definition.base_ts),
                import,
            );
        }
    }

    for schema in &catalog.schemas {
        for enum_definition in &schema.enums {
            let file = target_file(&options.output, "enums")?;
            push(
                &mut files,
                file,
                render_enum(enum_definition, options),
                Vec::new(),
            );
        }

        for table in &schema.tables {
            let file = target_file(&options.output, "tables")?;
            let mut imports = Vec::new();
            let name = table.name.to_case(Case::Pascal);
            let context = TableContext {
                brands: &brands,
                schema: &schema.name,
                table: &table.name,
                primary_key: &table.primary_key,
                foreign_keys: &table.foreign_keys,
                current_file: file,
                brands_file,
            };
            let declaration = render_object(
                &name,
                &table.columns,
                &table.comment,
                options,
                &mut imports,
                Some(&context),
            );
            push(&mut files, file, declaration, imports);
        }

        for view in &schema.views {
            let file = target_file(&options.output, "views")?;
            let mut imports = Vec::new();
            let name = view.name.to_case(Case::Pascal);
            let declaration = render_object(
                &name,
                &view.columns,
                &view.comment,
                options,
                &mut imports,
                None,
            );
            push(&mut files, file, declaration, imports);
        }
    }

    let files = files
        .into_iter()
        .map(|(name, contents)| GeneratedFile {
            path: PathBuf::from(name),
            contents: contents.render(),
        })
        .collect();

    Ok(EmitterOutput {
        files,
        warnings: Vec::new(),
    })
}

fn target_file<'a>(output: &'a Output, kind: &str) -> Result<&'a str, EmitError> {
    match output {
        Output::Single(file) => Ok(file),
        Output::Split(map) => map
            .get(kind)
            .or_else(|| map.get("default"))
            .map(String::as_str)
            .ok_or_else(|| EmitError::Options {
                emitter: TypescriptEmitter.name().into(),
                message: format!("no output file for kind '{kind}'; map it or add a `default`"),
            }),
    }
}

fn render_object(
    name: &str,
    columns: &[Column],
    comment: &Option<String>,
    options: &Options,
    imports: &mut Vec<String>,
    table: Option<&TableContext>,
) -> String {
    let mut writer = Writer::with_indent(&options.indent);

    if options.comments
        && let Some(text) = comment
    {
        write_doc(&mut writer, std::slice::from_ref(text));
    }

    let (open, close) = match options.type_style {
        TypeStyle::Interface => (format!("export interface {name} {{"), "}"),
        TypeStyle::Type => (format!("export type {name} = {{"), "};"),
    };

    writer.block(&open, close, |writer| {
        for column in columns {
            render_column(column, options, imports, writer, table);
        }
    });

    writer.finish()
}

fn render_column(
    column: &Column,
    options: &Options,
    imports: &mut Vec<String>,
    writer: &mut Writer,
    table: Option<&TableContext>,
) {
    let mut doc = Vec::new();

    if options.comments
        && let Some(text) = &column.comment
    {
        doc.push(text.clone());
    }

    if options.key_comments
        && let Some(context) = table
    {
        if context.primary_key.iter().any(|pk| pk == &column.name) {
            doc.push("PK".to_string());
        }

        if let Some(reference) = fk_reference(context.foreign_keys, &column.name) {
            doc.push(format!("FK {reference}"));
        }
    }

    write_doc(writer, &doc);

    let ty = match brand_ty(column, table, imports) {
        Some(branded) => branded,
        None => column_ts(&column.ty, options, imports),
    };

    let name = property_name(&column.name, &options.property_case);

    let line = match options.null_style {
        NullStyle::Union if column.nullable => format!("{name}: {ty} | null;"),
        NullStyle::Optional if column.nullable => format!("{name}?: {ty};"),
        _ => format!("{name}: {ty};"),
    };

    writer.line(&line);
}

/// A `references <schema>.<table>.<column>` note for a column that is part of a foreign
/// key. Columns map to `ref_columns` positionally, so this works for composite keys too.
fn fk_reference(foreign_keys: &[ForeignKey], column: &str) -> Option<String> {
    for foreign_key in foreign_keys {
        if let Some(index) = foreign_key.columns.iter().position(|c| c == column)
            && let Some(ref_column) = foreign_key.ref_columns.get(index)
        {
            let (schema, table) = &foreign_key.ref_table;
            return Some(format!("references {schema}.{table}.{ref_column}"));
        }
    }
    None
}

/// Emit a JSDoc comment, nothing for no lines, a one-liner for a single line, a block  otherwise.
fn write_doc(writer: &mut Writer, lines: &[String]) {
    match lines {
        [] => {}
        [single] => writer.line(&format!("/** {single} */")),
        many => {
            writer.line("/**");
            for line in many {
                writer.line(&format!(" * {line}"));
            }
            writer.line(" */");
        }
    }
}

/// The brand type for a column, if it carries one, importing it when the `brands` declarations
/// live in a different file than this column.
fn brand_ty(
    column: &Column,
    table: Option<&TableContext>,
    imports: &mut Vec<String>,
) -> Option<String> {
    let context = table?;

    let name = context
        .brands
        .brand_of(context.schema, context.table, &column.name)?;

    if let Some(brands_file) = context.brands_file
        && brands_file != context.current_file
    {
        imports.push(format!(
            "import type {{ {name} }} from '{}';",
            module_specifier(brands_file)
        ));
    }

    Some(name.to_string())
}

fn module_specifier(file: &str) -> String {
    format!("./{}", file.strip_suffix(".ts").unwrap_or(file))
}

/// The TypeScript type for a column, wrapping arrays and collecting any imports the
/// scalar override needs. Enum references resolve to the enum's PascalCase type name.
fn column_ts(ty: &ColumnType, options: &Options, imports: &mut Vec<String>) -> String {
    match ty {
        ColumnType::Scalar(scalar) => {
            let mapped = typemap::resolve(scalar, &options.types);
            if let Some(import) = mapped.import {
                imports.push(import);
            }
            mapped.ts
        }
        ColumnType::Array(inner) => format!("{}[]", column_ts(inner, options, imports)),
        ColumnType::Enum { name, .. } => name.to_case(Case::Pascal),
    }
}

fn render_brand(name: &str, base_ts: &str) -> String {
    let mut writer = Writer::new();
    writer.line(&format!(
        "export type {name} = {base_ts} & {{ readonly __brand: '{name}' }};"
    ));
    writer.finish()
}

fn render_enum(enum_definition: &Enum, options: &Options) -> String {
    let name = enum_definition.name.to_case(Case::Pascal);

    let mut writer = Writer::with_indent(&options.indent);

    match options.enum_style {
        EnumStyle::Union => {
            let members: Vec<String> = enum_definition
                .values
                .iter()
                .map(|value| format!("\"{value}\""))
                .collect();
            writer.line(&format!("export type {name} = {};", members.join(" | ")));
        }
        EnumStyle::Enum => {
            writer.block(&format!("export enum {name} {{"), "}", |writer| {
                for value in &enum_definition.values {
                    writer.line(&format!("{} = \"{value}\",", enum_member(value)));
                }
            });
        }
        EnumStyle::Const => {
            writer.block(
                &format!("export const {name} = {{"),
                "} as const;",
                |writer| {
                    for value in &enum_definition.values {
                        writer.line(&format!("{}: \"{value}\",", enum_member(value)));
                    }
                },
            );
            writer.line(&format!(
                "export type {name} = (typeof {name})[keyof typeof {name}];"
            ));
        }
    }

    writer.finish()
}

/// Append a rendered declaration (and any imports it needs) to its target file.
fn push(
    files: &mut BTreeMap<String, FileContents>,
    file: &str,
    declaration: String,
    imports: Vec<String>,
) {
    let contents = files.entry(file.to_string()).or_default();
    contents.declarations.push(declaration);
    contents.imports.extend(imports);
}

#[derive(Default)]
struct FileContents {
    imports: BTreeSet<String>,
    declarations: Vec<String>,
}

impl FileContents {
    fn render(self) -> String {
        let mut out = String::from(HEADER);

        if !self.imports.is_empty() {
            for import in &self.imports {
                out.push_str(import);
                out.push('\n');
            }
            out.push('\n');
        }

        out.push_str(&self.declarations.join("\n"));

        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::options::{BrandOptions, TypeOverride};
    use gieter_core::ir::{ScalarType, Schema, Table};
    use std::collections::BTreeMap;

    fn column(name: &str, ty: ColumnType, nullable: bool) -> Column {
        Column {
            name: name.into(),
            ty,
            nullable,
            comment: None,
        }
    }

    fn table(name: &str, columns: Vec<Column>) -> Table {
        Table {
            name: name.into(),
            schema: "public".into(),
            columns,
            primary_key: vec!["id".into()],
            foreign_keys: vec![],
            comment: None,
        }
    }

    fn one_schema(tables: Vec<Table>, enums: Vec<Enum>) -> Catalog {
        Catalog {
            schemas: vec![Schema {
                name: "public".into(),
                tables,
                enums,
                views: vec![],
            }],
        }
    }

    fn branded() -> Options {
        Options {
            brand: BrandOptions {
                enabled: true,
                extra: vec![],
            },
            ..Default::default()
        }
    }

    fn books_with_author_fk() -> Table {
        Table {
            name: "books".into(),
            schema: "public".into(),
            columns: vec![
                column("id", ColumnType::Scalar(ScalarType::Int32), false),
                column("author_id", ColumnType::Scalar(ScalarType::Int32), false),
            ],
            primary_key: vec!["id".into()],
            foreign_keys: vec![ForeignKey {
                name: "books_author_fk".into(),
                columns: vec!["author_id".into()],
                ref_table: ("public".into(), "authors".into()),
                ref_columns: vec!["id".into()],
            }],
            comment: None,
        }
    }

    #[test]
    fn renders_enums_and_a_table_into_one_file_with_defaults() {
        let catalog = one_schema(
            vec![table(
                "books",
                vec![
                    column("id", ColumnType::Scalar(ScalarType::Int32), false),
                    column(
                        "tags",
                        ColumnType::Array(Box::new(ColumnType::Scalar(ScalarType::Text {
                            max_len: None,
                        }))),
                        true,
                    ),
                    column(
                        "vibe",
                        ColumnType::Enum {
                            schema: "public".into(),
                            name: "mood".into(),
                        },
                        true,
                    ),
                ],
            )],
            vec![Enum {
                name: "mood".into(),
                schema: "public".into(),
                values: vec!["happy".into(), "sad".into()],
            }],
        );

        let output = render(&catalog, &Options::default()).unwrap();
        assert_eq!(output.files.len(), 1);
        assert_eq!(output.files[0].path, PathBuf::from("index.ts"));
        assert_eq!(
            output.files[0].contents,
            "// Generated by gieter. Do not edit manually.\n\n\
             export type Mood = \"happy\" | \"sad\";\n\n\
             export interface Books {\n  \
             /** PK */\n  \
             id: number;\n  \
             tags: string[] | null;\n  \
             vibe: Mood | null;\n\
             }\n"
        );
    }

    #[test]
    fn foreign_key_columns_get_a_reference_comment() {
        let catalog = one_schema(vec![books_with_author_fk()], vec![]);
        let output = render(&catalog, &Options::default()).unwrap();
        assert!(
            output.files[0]
                .contents
                .contains("/** FK references public.authors.id */")
        );
    }

    #[test]
    fn primary_key_columns_get_a_pk_comment() {
        let catalog = one_schema(
            vec![table(
                "books",
                vec![column("id", ColumnType::Scalar(ScalarType::Int32), false)],
            )],
            vec![],
        );
        let output = render(&catalog, &Options::default()).unwrap();
        assert!(output.files[0].contents.contains("/** PK */"));
    }

    #[test]
    fn key_comments_can_be_disabled() {
        let catalog = one_schema(vec![books_with_author_fk()], vec![]);
        let options = Options {
            key_comments: false,
            ..Default::default()
        };
        let output = render(&catalog, &options).unwrap();
        let contents = &output.files[0].contents;
        assert!(!contents.contains("PK"));
        assert!(!contents.contains("FK references"));
    }

    #[test]
    fn a_commented_foreign_key_column_gets_a_multi_line_doc() {
        let mut books = books_with_author_fk();
        books.columns[1].comment = Some("the writer".into());
        let output = render(&one_schema(vec![books], vec![]), &Options::default()).unwrap();
        let contents = &output.files[0].contents;
        assert!(contents.contains(" * the writer"));
        assert!(contents.contains(" * FK references public.authors.id"));
    }

    #[test]
    fn a_composite_foreign_key_references_columns_positionally() {
        let line_item = Table {
            name: "line_item".into(),
            schema: "public".into(),
            columns: vec![
                column("order_id", ColumnType::Scalar(ScalarType::Int32), false),
                column(
                    "sku",
                    ColumnType::Scalar(ScalarType::Text { max_len: None }),
                    false,
                ),
            ],
            primary_key: vec![],
            foreign_keys: vec![ForeignKey {
                name: "line_item_fk".into(),
                columns: vec!["order_id".into(), "sku".into()],
                ref_table: ("public".into(), "order_sku".into()),
                ref_columns: vec!["oid".into(), "code".into()],
            }],
            comment: None,
        };
        let output = render(&one_schema(vec![line_item], vec![]), &Options::default()).unwrap();
        let contents = &output.files[0].contents;
        assert!(contents.contains("/** FK references public.order_sku.oid */"));
        assert!(contents.contains("/** FK references public.order_sku.code */"));
    }

    #[test]
    fn a_configured_indent_is_applied() {
        let catalog = one_schema(
            vec![table(
                "books",
                vec![column("id", ColumnType::Scalar(ScalarType::Int32), false)],
            )],
            vec![],
        );
        let options = Options {
            indent: "\t".into(),
            ..Default::default()
        };
        let output = render(&catalog, &options).unwrap();
        assert!(output.files[0].contents.contains("\tid: number;"));
    }

    #[test]
    fn splits_kinds_across_files_and_hoists_a_scalar_import() {
        let catalog = one_schema(
            vec![table(
                "books",
                vec![column("id", ColumnType::Scalar(ScalarType::Uuid), false)],
            )],
            vec![Enum {
                name: "mood".into(),
                schema: "public".into(),
                values: vec!["happy".into()],
            }],
        );

        let options = Options {
            output: Output::Split(BTreeMap::from([
                ("enums".to_string(), "enums.ts".to_string()),
                ("default".to_string(), "db.ts".to_string()),
            ])),
            types: BTreeMap::from([(
                "uuid".to_string(),
                TypeOverride {
                    ts: "UUID".into(),
                    import: Some("import type { UUID } from './uuid';".into()),
                },
            )]),
            ..Default::default()
        };

        let output = render(&catalog, &options).unwrap();
        let names: Vec<&str> = output
            .files
            .iter()
            .map(|file| file.path.to_str().unwrap())
            .collect();
        assert_eq!(names, ["db.ts", "enums.ts"]);

        let db = output
            .files
            .iter()
            .find(|file| file.path.to_str() == Some("db.ts"))
            .unwrap();
        assert!(db.contents.contains("import type { UUID } from './uuid';"));
        assert!(db.contents.contains("id: UUID;"));
    }

    #[test]
    fn an_unmapped_kind_without_a_default_is_an_error() {
        let catalog = one_schema(
            vec![table(
                "books",
                vec![column("id", ColumnType::Scalar(ScalarType::Int32), false)],
            )],
            vec![],
        );

        let options = Options {
            output: Output::Split(BTreeMap::from([(
                "enums".to_string(),
                "enums.ts".to_string(),
            )])),
            ..Default::default()
        };

        assert!(render(&catalog, &options).is_err());
    }

    #[test]
    fn branding_emits_brand_types_and_references_them_in_one_file() {
        let authors = table(
            "authors",
            vec![column("id", ColumnType::Scalar(ScalarType::Int32), false)],
        );
        let catalog = one_schema(vec![authors, books_with_author_fk()], vec![]);

        let output = render(&catalog, &branded()).unwrap();
        assert_eq!(output.files.len(), 1);
        let contents = &output.files[0].contents;
        assert!(
            contents
                .contains("export type AuthorsId = number & { readonly __brand: 'AuthorsId' };")
        );
        assert!(
            contents.contains("export type BooksId = number & { readonly __brand: 'BooksId' };")
        );
        assert!(contents.contains("id: BooksId;"));
        assert!(contents.contains("author_id: AuthorsId;"));
        // brands share the file, so no import is generated.
        assert!(!contents.contains("import type"));
    }

    #[test]
    fn branding_imports_brand_types_when_split_into_another_file() {
        let catalog = one_schema(
            vec![table(
                "books",
                vec![column("id", ColumnType::Scalar(ScalarType::Int32), false)],
            )],
            vec![],
        );

        let options = Options {
            brand: BrandOptions {
                enabled: true,
                extra: vec![],
            },
            output: Output::Split(BTreeMap::from([
                ("tables".to_string(), "db.ts".to_string()),
                ("brands".to_string(), "brands.ts".to_string()),
            ])),
            ..Default::default()
        };

        let output = render(&catalog, &options).unwrap();
        let db = output
            .files
            .iter()
            .find(|file| file.path.to_str() == Some("db.ts"))
            .unwrap();
        assert!(
            db.contents
                .contains("import type { BooksId } from './brands';")
        );
        assert!(db.contents.contains("id: BooksId;"));

        let brands = output
            .files
            .iter()
            .find(|file| file.path.to_str() == Some("brands.ts"))
            .unwrap();
        assert!(
            brands
                .contents
                .contains("export type BooksId = number & { readonly __brand: 'BooksId' };")
        );
    }
}
