use anyhow::Result;
use auditaur_core::{
    model::{FrontendError, TauriIpcCall},
    storage::{FrontendErrorQuery, TauriIpcQuery},
};
use serde::Serialize;
use std::{
    collections::{BTreeMap, BTreeSet},
    fs,
    hash::{Hash, Hasher},
    path::PathBuf,
};

use crate::{commands::read, discovery, output::table_cell};

pub struct ExceptionOptions {
    pub session_id: Option<String>,
    pub trace_id: Option<String>,
    pub since: Option<String>,
    pub fingerprint: Option<String>,
    pub json: bool,
    pub markdown: bool,
    pub output: Option<PathBuf>,
    pub limit: usize,
}

pub fn run(db: &Option<PathBuf>, options: ExceptionOptions) -> Result<()> {
    let db = discovery::resolve_db(db.clone())?;
    let store = read::open_validated_store(&db)?;
    let mut frontend_errors = store.list_frontend_errors(&FrontendErrorQuery {
        session_id: options.session_id.clone(),
        trace_id: options.trace_id.clone(),
        limit: Some(usize::MAX),
    })?;
    let mut ipc_calls = store.list_tauri_ipc_calls(&TauriIpcQuery {
        session_id: options.session_id,
        trace_id: options.trace_id,
        limit: Some(usize::MAX),
    })?;

    if let Some(cutoff) = read::parse_since_cutoff(options.since.as_deref())? {
        frontend_errors.retain(|error| error.timestamp_unix_nanos >= cutoff);
        ipc_calls.retain(|call| call.timestamp_unix_nanos >= cutoff);
    }
    ipc_calls.retain(read::is_failed_ipc);

    let mut reports = exception_reports(exception_candidates(frontend_errors, ipc_calls));
    if let Some(fingerprint) = &options.fingerprint {
        reports.retain(|report| report.fingerprint == *fingerprint);
    }
    reports.truncate(options.limit);

    if options.markdown {
        let markdown = render_markdown(&reports);
        if let Some(output) = options.output {
            fs::write(output, markdown)?;
        } else {
            print!("{markdown}");
        }
        return Ok(());
    }
    if let Some(output) = options.output {
        fs::write(output, serde_json::to_string_pretty(&reports)?)?;
        return Ok(());
    }
    read::print_json_or_table(options.json, &reports, || print_table(&reports))
}

fn exception_candidates(
    frontend_errors: Vec<FrontendError>,
    ipc_calls: Vec<TauriIpcCall>,
) -> Vec<ExceptionCandidate> {
    frontend_errors
        .into_iter()
        .map(ExceptionCandidate::from_frontend_error)
        .chain(ipc_calls.into_iter().map(ExceptionCandidate::from_ipc_call))
        .collect()
}

fn exception_reports(candidates: Vec<ExceptionCandidate>) -> Vec<ExceptionReport> {
    let mut groups: BTreeMap<String, ExceptionGroup> = BTreeMap::new();
    for candidate in candidates {
        let fingerprint = exception_fingerprint(&candidate);
        groups.entry(fingerprint).or_default().push(candidate);
    }

    let mut reports: Vec<_> = groups
        .into_iter()
        .map(|(fingerprint, group)| group.into_report(fingerprint))
        .collect();
    reports.sort_by(|left, right| {
        right
            .last_seen_unix_nanos
            .cmp(&left.last_seen_unix_nanos)
            .then_with(|| right.count.cmp(&left.count))
    });
    reports
}

fn exception_fingerprint(candidate: &ExceptionCandidate) -> String {
    let mut hasher = StableHasher::default();
    candidate.source.hash(&mut hasher);
    normalized(Some(&candidate.kind)).hash(&mut hasher);
    if candidate.source != ExceptionSource::FailedIpc {
        normalized_for_grouping(Some(&candidate.message)).hash(&mut hasher);
    }
    normalized(candidate.grouping_key.as_deref()).hash(&mut hasher);
    format!("ex_{:016x}", hasher.finish())
}

fn normalized(value: Option<&str>) -> String {
    value
        .unwrap_or("")
        .trim()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

fn normalized_for_grouping(value: Option<&str>) -> String {
    let normalized = normalized(value);
    let mut output = String::with_capacity(normalized.len());
    let mut previous_was_placeholder = false;
    for part in normalized.split(' ') {
        let scrubbed = if is_dynamic_token(part) {
            "<var>"
        } else {
            part
        };
        if !output.is_empty() {
            output.push(' ');
        }
        if scrubbed == "<var>" && previous_was_placeholder {
            continue;
        }
        output.push_str(scrubbed);
        previous_was_placeholder = scrubbed == "<var>";
    }
    output
}

fn is_dynamic_token(value: &str) -> bool {
    let trimmed = value.trim_matches(|char: char| {
        matches!(
            char,
            ',' | '.' | ':' | ';' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\''
        )
    });
    trimmed.chars().any(|char| char.is_ascii_digit())
        && trimmed
            .chars()
            .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
}

fn top_stack_frame(stack: Option<&str>) -> Option<&str> {
    stack?
        .lines()
        .map(str::trim)
        .find(|line| line.starts_with("at ") || line.contains('@'))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
enum ExceptionSource {
    FrontendError,
    RustPanic,
    FailedIpc,
}

impl ExceptionSource {
    fn as_str(self) -> &'static str {
        match self {
            Self::FrontendError => "frontend_error",
            Self::RustPanic => "rust_panic",
            Self::FailedIpc => "failed_ipc",
        }
    }
}

#[derive(Debug, Clone)]
struct ExceptionCandidate {
    source: ExceptionSource,
    session_id: String,
    timestamp_unix_nanos: i64,
    message: String,
    kind: String,
    trace_id: Option<String>,
    span_id: Option<String>,
    window_label: Option<String>,
    location: Option<String>,
    stack: Option<String>,
    grouping_key: Option<String>,
    context: BTreeMap<String, String>,
}

impl ExceptionCandidate {
    fn from_frontend_error(error: FrontendError) -> Self {
        let source = if error
            .attributes
            .get("auditaur.source")
            .and_then(serde_json::Value::as_str)
            == Some("panic_hook")
        {
            ExceptionSource::RustPanic
        } else {
            ExceptionSource::FrontendError
        };
        let location = error_location(&error);
        let grouping_key = top_stack_frame(error.stack.as_deref())
            .map(str::to_string)
            .or_else(|| location.clone());
        let mut context = BTreeMap::new();
        if let Some(filename) = &error.filename {
            context.insert("filename".to_string(), filename.clone());
        }
        if let Some(line) = error.line_number {
            context.insert("line".to_string(), line.to_string());
        }
        if let Some(column) = error.column_number {
            context.insert("column".to_string(), column.to_string());
        }
        Self {
            source,
            session_id: error.session_id,
            timestamp_unix_nanos: error.timestamp_unix_nanos,
            message: error.message,
            kind: error.error_type.unwrap_or_else(|| "Error".to_string()),
            trace_id: error.trace_id,
            span_id: error.span_id,
            window_label: error.window_label,
            location,
            stack: error.stack,
            grouping_key,
            context,
        }
    }

    fn from_ipc_call(call: TauriIpcCall) -> Self {
        let mut context = BTreeMap::new();
        context.insert("command".to_string(), call.command.clone());
        context.insert("status".to_string(), call.status.clone());
        if let Some(duration_ms) = call.duration_ms {
            context.insert("durationMs".to_string(), duration_ms.to_string());
        }
        let message = call.error_message.clone().unwrap_or_else(|| {
            format!(
                "Tauri IPC command `{}` failed with status {}",
                call.command, call.status
            )
        });
        Self {
            source: ExceptionSource::FailedIpc,
            session_id: call.session_id,
            timestamp_unix_nanos: call.timestamp_unix_nanos,
            message,
            kind: format!("Tauri IPC {}", call.command),
            trace_id: call.trace_id,
            span_id: call.span_id,
            window_label: call.window_label,
            location: Some(format!("ipc:{}", call.command)),
            stack: None,
            grouping_key: Some(call.command),
            context,
        }
    }
}

#[derive(Default)]
struct ExceptionGroup {
    candidates: Vec<ExceptionCandidate>,
}

impl ExceptionGroup {
    fn push(&mut self, candidate: ExceptionCandidate) {
        self.candidates.push(candidate);
    }

    fn into_report(self, fingerprint: String) -> ExceptionReport {
        let first = self.candidates.first().expect("group is never empty");
        let mut first_seen = first.timestamp_unix_nanos;
        let mut last_seen = first.timestamp_unix_nanos;
        let mut sessions = BTreeSet::new();
        let mut traces = BTreeSet::new();
        let mut spans = BTreeSet::new();
        let mut windows = BTreeSet::new();
        let mut locations = BTreeSet::new();
        let mut sample_stack = None;
        let mut sample_context = BTreeMap::new();

        for candidate in &self.candidates {
            first_seen = first_seen.min(candidate.timestamp_unix_nanos);
            last_seen = last_seen.max(candidate.timestamp_unix_nanos);
            sessions.insert(candidate.session_id.clone());
            if let Some(trace_id) = &candidate.trace_id {
                traces.insert(trace_id.clone());
            }
            if let Some(span_id) = &candidate.span_id {
                spans.insert(span_id.clone());
            }
            if let Some(window_label) = &candidate.window_label {
                windows.insert(window_label.clone());
            }
            if let Some(location) = &candidate.location {
                locations.insert(location.clone());
            }
            if sample_stack.is_none() {
                sample_stack = candidate.stack.clone();
            }
            if sample_context.is_empty() {
                sample_context = candidate.context.clone();
            }
        }

        let report = ExceptionReport {
            fingerprint,
            source: first.source,
            message: first.message.clone(),
            kind: first.kind.clone(),
            count: self.candidates.len(),
            first_seen_unix_nanos: first_seen,
            last_seen_unix_nanos: last_seen,
            sessions: sessions.into_iter().collect(),
            traces: traces.into_iter().collect(),
            spans: spans.into_iter().collect(),
            windows: windows.into_iter().collect(),
            locations: locations.into_iter().collect(),
            sample_context,
            sample_stack,
            issue_title: String::new(),
            issue_body_markdown: String::new(),
        };
        report.with_issue_fields()
    }
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ExceptionReport {
    fingerprint: String,
    source: ExceptionSource,
    message: String,
    kind: String,
    count: usize,
    first_seen_unix_nanos: i64,
    last_seen_unix_nanos: i64,
    sessions: Vec<String>,
    traces: Vec<String>,
    spans: Vec<String>,
    windows: Vec<String>,
    locations: Vec<String>,
    sample_context: BTreeMap<String, String>,
    sample_stack: Option<String>,
    issue_title: String,
    issue_body_markdown: String,
}

impl ExceptionReport {
    fn with_issue_fields(mut self) -> Self {
        self.issue_title = markdown_line(&format!("{}: {}", self.kind, self.message));
        self.issue_body_markdown = issue_body_markdown(&self);
        self
    }
}

fn issue_body_markdown(report: &ExceptionReport) -> String {
    let mut body = String::new();
    body.push_str("## Auditaur exception report\n\n");
    body.push_str(&format!("- Fingerprint: `{}`\n", report.fingerprint));
    body.push_str(&format!("- Source: `{}`\n", report.source.as_str()));
    body.push_str(&format!("- Count: {}\n", report.count));
    body.push_str(&format!(
        "- First seen: `{}`\n",
        report.first_seen_unix_nanos
    ));
    body.push_str(&format!("- Last seen: `{}`\n", report.last_seen_unix_nanos));
    body.push_str(&format!("- Kind: {}\n", markdown_line(&report.kind)));
    body.push_str(&format!("- Message: {}\n", markdown_line(&report.message)));
    if !report.locations.is_empty() {
        body.push_str(&format!("- Locations: {}\n", report.locations.join(", ")));
    }
    if !report.windows.is_empty() {
        body.push_str(&format!("- Windows: {}\n", report.windows.join(", ")));
    }
    if !report.traces.is_empty() {
        body.push_str(&format!("- Trace IDs: {}\n", report.traces.join(", ")));
    }
    if !report.sample_context.is_empty() {
        body.push_str("\n## Sample context\n\n");
        for (key, value) in &report.sample_context {
            body.push_str(&format!(
                "- `{}`: `{}`\n",
                markdown_line(key),
                markdown_line(value)
            ));
        }
    }
    body.push_str("\n## Privacy note\n\n");
    body.push_str("Review and redact this local report before posting it to a public tracker.\n");
    if let Some(stack) = &report.sample_stack {
        body.push_str("\n## Sample stack\n\n```text\n");
        body.push_str(&escape_code_fence(stack));
        body.push_str("\n```\n");
    }
    body
}

fn markdown_line(value: &str) -> String {
    value.replace(['\r', '\n'], " ")
}

fn escape_code_fence(value: &str) -> String {
    value.replace("```", "`` `")
}

fn error_location(error: &FrontendError) -> Option<String> {
    let filename = error.filename.as_ref()?;
    Some(match (error.line_number, error.column_number) {
        (Some(line), Some(column)) => format!("{filename}:{line}:{column}"),
        (Some(line), None) => format!("{filename}:{line}"),
        _ => filename.clone(),
    })
}

fn print_table(reports: &[ExceptionReport]) -> Result<()> {
    println!("FINGERPRINT\tSOURCE\tCOUNT\tLAST_SEEN\tKIND\tMESSAGE");
    for report in reports {
        println!(
            "{}\t{}\t{}\t{}\t{}\t{}",
            report.fingerprint,
            report.source.as_str(),
            report.count,
            report.last_seen_unix_nanos,
            table_cell(&report.kind, 60),
            table_cell(&report.message, 160)
        );
    }
    Ok(())
}

fn render_markdown(reports: &[ExceptionReport]) -> String {
    let mut output = String::new();
    for (index, report) in reports.iter().enumerate() {
        if index > 0 {
            output.push_str("\n---\n\n");
        }
        output.push_str(&format!("# {}\n\n", report.issue_title));
        output.push_str(&report.issue_body_markdown);
        if !output.ends_with('\n') {
            output.push('\n');
        }
    }
    output
}

struct StableHasher(u64);

impl Default for StableHasher {
    fn default() -> Self {
        Self(0xcbf29ce484222325)
    }
}

impl Hasher for StableHasher {
    fn finish(&self) -> u64 {
        self.0
    }

    fn write(&mut self, bytes: &[u8]) {
        for byte in bytes {
            self.0 ^= u64::from(*byte);
            self.0 = self.0.wrapping_mul(0x100000001b3);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{exception_candidates, exception_fingerprint, exception_reports, ExceptionSource};
    use auditaur_core::model::{FrontendError, TauriIpcCall};
    use serde_json::json;

    #[test]
    fn groups_repeated_frontend_errors_into_issue_ready_reports() {
        let candidates = exception_candidates(
            vec![
                test_error("session-a", 100, Some("trace-a")),
                test_error("session-a", 200, Some("trace-b")),
                FrontendError {
                    message: "different".to_string(),
                    timestamp_unix_nanos: 300,
                    ..test_error("session-b", 300, None)
                },
            ],
            Vec::new(),
        );

        let reports = exception_reports(candidates);

        assert_eq!(reports.len(), 2);
        assert_eq!(reports[0].count, 1);
        assert_eq!(reports[1].count, 2);
        assert!(reports[1].issue_body_markdown.contains("Privacy note"));
        assert!(reports[1].traces.contains(&"trace-a".to_string()));
        assert!(reports[1].traces.contains(&"trace-b".to_string()));
    }

    #[test]
    fn includes_failed_ipc_calls_as_exception_reports() {
        let reports = exception_reports(exception_candidates(
            Vec::new(),
            vec![
                test_ipc("session-a", 100, Some("trace-a")),
                test_ipc("session-b", 200, Some("trace-b")),
            ],
        ));

        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].source, ExceptionSource::FailedIpc);
        assert_eq!(reports[0].count, 2);
        assert!(reports[0].sample_context.contains_key("command"));
        assert!(reports[0]
            .issue_body_markdown
            .contains("Tauri IPC fixture_command"));
    }

    #[test]
    fn ipc_fingerprints_group_varying_error_messages_for_same_command() {
        let mut first = test_ipc("session-a", 100, Some("trace-a"));
        first.error_message = Some("failed for user 123".to_string());
        let mut second = test_ipc("session-a", 200, Some("trace-b"));
        second.error_message = Some("failed for user 456".to_string());

        let reports = exception_reports(exception_candidates(Vec::new(), vec![first, second]));

        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].count, 2);
    }

    #[test]
    fn panic_hook_frontend_errors_are_classified_as_rust_panics() {
        let reports = exception_reports(exception_candidates(vec![test_panic()], Vec::new()));

        assert_eq!(reports[0].source, ExceptionSource::RustPanic);
        assert_eq!(reports[0].kind, "Panic");
    }

    #[test]
    fn dynamic_tokens_do_not_split_frontend_exception_groups() {
        let mut first = test_error("session-a", 100, Some("trace-a"));
        first.message = "Failed to load user 123e4567-e89b-12d3-a456-426614174000".to_string();
        let mut second = test_error("session-a", 200, Some("trace-b"));
        second.message = "Failed to load user 987e6543-e21b-12d3-a456-426614174999".to_string();

        let reports = exception_reports(exception_candidates(vec![first, second], Vec::new()));

        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].count, 2);
    }

    #[test]
    fn markdown_escapes_stack_code_fences() {
        let mut error = test_error("session-a", 100, None);
        error.stack = Some("before\n```\nafter".to_string());

        let reports = exception_reports(exception_candidates(vec![error], Vec::new()));

        assert!(reports[0].issue_body_markdown.contains("`` `"));
        assert!(!reports[0].issue_body_markdown.contains("\n```\nafter"));
    }

    #[test]
    fn fingerprints_are_stable_for_matching_error_shapes() {
        let left = exception_candidates(
            vec![test_error("session-a", 100, Some("trace-a"))],
            Vec::new(),
        )
        .pop()
        .unwrap();
        let right = exception_candidates(
            vec![test_error("session-b", 200, Some("trace-b"))],
            Vec::new(),
        )
        .pop()
        .unwrap();
        assert_eq!(exception_fingerprint(&left), exception_fingerprint(&right));
    }

    fn test_error(
        session_id: &str,
        timestamp_unix_nanos: i64,
        trace_id: Option<&str>,
    ) -> FrontendError {
        FrontendError {
            session_id: session_id.to_string(),
            timestamp_unix_nanos,
            message: "boom".to_string(),
            stack: Some("Error: boom\n    at doThing (main.ts:10:2)".to_string()),
            filename: Some("main.ts".to_string()),
            line_number: Some(10),
            column_number: Some(2),
            error_type: Some("Error".to_string()),
            trace_id: trace_id.map(str::to_string),
            span_id: None,
            window_label: Some("main".to_string()),
            attributes: json!({ "auditaur.source": "frontend" }),
        }
    }

    fn test_panic() -> FrontendError {
        FrontendError {
            message: "panic boom".to_string(),
            error_type: Some("Panic".to_string()),
            attributes: json!({ "auditaur.source": "panic_hook" }),
            ..test_error("session-a", 100, None)
        }
    }

    fn test_ipc(
        session_id: &str,
        timestamp_unix_nanos: i64,
        trace_id: Option<&str>,
    ) -> TauriIpcCall {
        TauriIpcCall {
            session_id: session_id.to_string(),
            timestamp_unix_nanos,
            duration_ms: Some(3.0),
            command: "fixture_command".to_string(),
            status: "ERROR".to_string(),
            error_message: Some("fixture failure".to_string()),
            trace_id: trace_id.map(str::to_string),
            span_id: Some("span-fixture".to_string()),
            window_label: Some("main".to_string()),
            args_json: None,
            args_redacted: true,
            result_summary: None,
        }
    }
}
