use std::{
    collections::HashMap,
    path::PathBuf,
    sync::Arc,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::Context;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};

use crate::{
    app::AppState,
    domain::{Resource, ResourceId},
    github::{
        api::{ApiDepth, GithubApiGateway, GithubGateway},
        load_fixture,
    },
};

#[derive(Debug, Clone)]
pub(crate) enum FetchAction {
    Initial { id: ResourceId },
    Refresh { id: ResourceId },
    LoadFull { id: ResourceId },
    OpenTab { id: ResourceId },
    Navigate { from: ResourceId, to: ResourceId },
    Back { to: ResourceId },
}

impl FetchAction {
    pub(crate) fn target(&self) -> &ResourceId {
        match self {
            Self::Initial { id }
            | Self::Refresh { id }
            | Self::LoadFull { id }
            | Self::OpenTab { id } => id,
            Self::Navigate { to, .. } | Self::Back { to } => to,
        }
    }

    pub(crate) fn loading_message(&self) -> String {
        match self {
            Self::Initial { id } => format!("opening {} from GitHub", id.canonical_name()),
            Self::Refresh { id } => format!("refreshing {} from GitHub", id.canonical_name()),
            Self::LoadFull { id } => {
                format!("loading full data for {} from GitHub", id.canonical_name())
            }
            Self::OpenTab { id } => format!("opening {} in a new tab", id.canonical_name()),
            Self::Navigate { to, .. } => format!("opening {} from GitHub", to.canonical_name()),
            Self::Back { to } => format!("returning to {} from GitHub", to.canonical_name()),
        }
    }
}

pub(crate) struct FetchOutcome {
    action: FetchAction,
    result: anyhow::Result<Resource>,
    refreshed_at: String,
    request_id: u64,
    origin_tab_id: u64,
}

#[derive(Clone)]
pub(crate) enum FetchSource {
    Github(GithubApiGateway),
    OfflineFixtures(OfflineFixtureSource),
}

impl FetchSource {
    pub(crate) async fn fetch_resource(&self, id: &ResourceId) -> anyhow::Result<Resource> {
        match self {
            Self::Github(gateway) => gateway.fetch_resource(id).await,
            Self::OfflineFixtures(fixtures) => fixtures.fetch_resource(id),
        }
    }

    async fn fetch_resource_full_depth(&self, id: &ResourceId) -> anyhow::Result<Resource> {
        match self {
            Self::Github(_) => {
                GithubApiGateway::new(ApiDepth::Full)
                    .fetch_resource(id)
                    .await
            }
            Self::OfflineFixtures(fixtures) => fixtures.fetch_resource(id),
        }
    }

    pub(crate) fn is_live_github(&self) -> bool {
        matches!(self, Self::Github(_))
    }

    pub(crate) fn is_offline_fixture(&self) -> bool {
        matches!(self, Self::OfflineFixtures(_))
    }
}

#[derive(Clone)]
pub(crate) struct OfflineFixtureSource {
    resources: Arc<HashMap<String, Resource>>,
}

impl OfflineFixtureSource {
    pub(crate) fn new(resources: impl IntoIterator<Item = Resource>) -> Self {
        Self {
            resources: Arc::new(
                resources
                    .into_iter()
                    .map(|resource| (resource.id.canonical_name(), resource))
                    .collect(),
            ),
        }
    }

    pub(crate) fn from_primary_and_paths(
        primary: Resource,
        extra_paths: &[PathBuf],
    ) -> anyhow::Result<Self> {
        let mut resources = vec![primary];
        for path in extra_paths {
            resources.push(load_fixture(path)?);
        }
        Ok(Self::new(resources))
    }

    pub(crate) fn fetch_resource(&self, id: &ResourceId) -> anyhow::Result<Resource> {
        let key = id.canonical_name();
        self.resources
            .get(&key)
            .cloned()
            .with_context(|| format!("offline fixture mode: no fixture loaded for {key}"))
    }
}

pub(crate) fn start_background_fetch(
    state: &mut AppState,
    action: FetchAction,
    fetch_source: FetchSource,
    fetch_tx: &UnboundedSender<FetchOutcome>,
) -> bool {
    if let Some(message) = state.loading_message() {
        state.status_message = Some(format!("still loading: {message}"));
        return false;
    }

    let target = action.target().clone();
    let message = action.loading_message();
    let origin_tab_id = state.active_resource_tab_id();
    let request_id = state.begin_loading(target.clone(), message);
    let tx = fetch_tx.clone();
    tokio::spawn(async move {
        let result = match &action {
            FetchAction::LoadFull { .. } => fetch_source.fetch_resource_full_depth(&target).await,
            _ => fetch_source.fetch_resource(&target).await,
        };
        let _ = tx.send(FetchOutcome {
            action,
            result,
            refreshed_at: current_refresh_label(),
            request_id,
            origin_tab_id,
        });
    });
    true
}

pub(crate) fn apply_completed_fetches(
    state: &mut AppState,
    fetch_rx: &mut UnboundedReceiver<FetchOutcome>,
) -> bool {
    let mut changed = false;
    while let Ok(outcome) = fetch_rx.try_recv() {
        apply_fetch_outcome(state, outcome);
        changed = true;
    }
    changed
}

pub(crate) fn apply_fetch_outcome(state: &mut AppState, outcome: FetchOutcome) {
    if !state.loading_request_matches(outcome.request_id) {
        return;
    }
    state.finish_loading();
    state.clear_transient_loading_status_messages();
    let origin_tab_id = outcome.origin_tab_id;
    match (outcome.action, outcome.result) {
        (FetchAction::Initial { .. }, Ok(resource)) => {
            state.apply_to_resource_tab(origin_tab_id, |state| {
                state.replace_resource_preserve_tab(resource);
                state.last_error = None;
                state.status_message = None;
            });
        }
        (FetchAction::Refresh { .. }, Ok(resource)) => {
            state.apply_to_resource_tab(origin_tab_id, |state| {
                state.apply_refreshed_resource(resource, outcome.refreshed_at);
            });
        }
        (FetchAction::LoadFull { .. }, Ok(resource)) => {
            state.apply_to_resource_tab(origin_tab_id, |state| {
                state.apply_refreshed_resource(resource, outcome.refreshed_at);
                state.status_message = None;
            });
        }
        (FetchAction::OpenTab { .. }, Ok(resource)) => {
            state.open_resource_in_tab(resource);
            state.last_error = None;
            state.status_message = None;
        }
        (FetchAction::Navigate { from, .. }, Ok(resource)) => {
            state.apply_to_resource_tab(origin_tab_id, |state| {
                state.history.push(from);
                state.replace_resource_reset_view(resource);
                state.last_error = None;
                state.status_message = None;
            });
        }
        (FetchAction::Back { .. }, Ok(resource)) => {
            state.apply_to_resource_tab(origin_tab_id, |state| {
                state.replace_resource_reset_view(resource);
                state.last_error = None;
                state.status_message = None;
            });
        }
        (FetchAction::Back { to }, Err(error)) => {
            state.apply_to_resource_tab(origin_tab_id, |state| {
                state.history.push(to);
                state.last_error = Some(error.to_string());
            });
        }
        (_, Err(error)) => {
            state.apply_to_resource_tab(origin_tab_id, |state| {
                state.last_error = Some(error.to_string());
            });
        }
    }
}

fn current_refresh_label() -> String {
    let seconds = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
        % 86_400;
    let hours = seconds / 3_600;
    let minutes = (seconds % 3_600) / 60;
    let seconds = seconds % 60;
    format!("{hours:02}:{minutes:02}:{seconds:02} UTC")
}

#[cfg(test)]
mod tests {
    use std::time::{Duration, Instant};

    use crate::{
        app::{loading_resource_placeholder, AppState, Tab},
        domain::{ReactionCounts, Resource, ResourceId, ResourceKind, FULL_DEPTH_WARNING_HINT},
        github::api::GithubApiGateway,
        runner::maybe_auto_refresh_with_start,
    };

    use super::{
        apply_fetch_outcome, start_background_fetch, FetchAction, FetchOutcome, FetchSource,
        OfflineFixtureSource,
    };

    fn issue_resource(number: u64, title: &str) -> Resource {
        Resource {
            id: ResourceId {
                owner: "owner".into(),
                repo: "repo".into(),
                number,
                kind_hint: Some(ResourceKind::Issue),
            },
            title: title.into(),
            url: format!("https://github.com/owner/repo/issues/{number}"),
            state: "OPEN".into(),
            author: "alice".into(),
            created_at: "now".into(),
            updated_at: "now".into(),
            labels: vec![],
            assignees: vec![],
            reactions: ReactionCounts::default(),
            body: "Body".into(),
            activity: vec![],
            related_resources: vec![],
            metadata: vec![],
            warnings: vec![],
            pull_request: None,
        }
    }

    fn begin_test_fetch(state: &mut AppState, action: &FetchAction) -> (u64, u64) {
        let origin_tab_id = state.active_resource_tab_id();
        let request_id = state.begin_loading(action.target().clone(), action.loading_message());
        (request_id, origin_tab_id)
    }

    #[test]
    fn automatic_refresh_starts_background_fetch_and_records_completed_changes() {
        let initial = issue_resource(1, "Initial issue");
        let mut refreshed_resource = issue_resource(1, "Updated issue");
        refreshed_resource.body = "Updated body".into();
        let mut state = AppState::new(initial);
        let mut last_refresh = Instant::now();
        let now = last_refresh + Duration::from_secs(30);
        let mut started = Vec::new();

        let refreshed = maybe_auto_refresh_with_start(
            &mut state,
            true,
            Duration::from_secs(30),
            &mut last_refresh,
            now,
            |state, action| {
                let (request_id, origin_tab_id) = begin_test_fetch(state, &action);
                started.push((action, request_id, origin_tab_id));
                true
            },
        );

        assert!(refreshed);
        assert_eq!(last_refresh, now);
        assert_eq!(
            state.loading_message(),
            Some("refreshing owner/repo#1 from GitHub")
        );
        assert_eq!(started.len(), 1);
        let (action, request_id, origin_tab_id) = started.pop().unwrap();
        apply_fetch_outcome(
            &mut state,
            FetchOutcome {
                action,
                result: Ok(refreshed_resource),
                refreshed_at: "12:34:56 UTC".into(),
                request_id,
                origin_tab_id,
            },
        );

        assert_eq!(state.resource.title, "Updated issue");
        assert_eq!(state.resource.body, "Updated body");
        assert_eq!(state.last_refresh_had_changes, Some(true));
        assert_eq!(state.last_refresh_changed_sections, ["summary"]);
        assert!(state.loading.is_none());
    }

    #[test]
    fn full_depth_fetch_outcome_preserves_view_and_reports_loaded_status() {
        let mut initial = issue_resource(1, "Initial issue");
        initial.warnings.push(format!(
            "normal API depth shows the first 100 only for comments; {FULL_DEPTH_WARNING_HINT} for exhaustive pagination"
        ));
        let mut full = issue_resource(1, "Initial issue");
        full.body = "full body with later comments".into();
        let mut state = AppState::new(initial);
        state.set_tab(Tab::Activity);
        state.set_scroll_limit(10);
        state.scroll_down(4);
        let id = state.resource.id.clone();
        let action = FetchAction::LoadFull { id };
        let (request_id, origin_tab_id) = begin_test_fetch(&mut state, &action);

        apply_fetch_outcome(
            &mut state,
            FetchOutcome {
                action,
                result: Ok(full),
                refreshed_at: "12:34:56 UTC".into(),
                request_id,
                origin_tab_id,
            },
        );

        assert_eq!(state.active_tab, Tab::Activity);
        assert_eq!(state.scroll, 4);
        assert_eq!(state.resource.body, "full body with later comments");
        assert!(!state.resource.has_partial_depth_warning());
        assert!(state.status_message.is_none());
    }

    #[test]
    fn initial_fetch_outcome_replaces_placeholder_with_loaded_resource() {
        let id = ResourceId {
            owner: "owner".into(),
            repo: "repo".into(),
            number: 1,
            kind_hint: None,
        };
        let mut state = AppState::new(loading_resource_placeholder(id.clone()));
        state.set_tab(Tab::Files);
        let action = FetchAction::Initial { id };
        let (request_id, origin_tab_id) = begin_test_fetch(&mut state, &action);

        apply_fetch_outcome(
            &mut state,
            FetchOutcome {
                action,
                result: Ok(issue_resource(1, "Loaded issue")),
                refreshed_at: "12:34:56 UTC".into(),
                request_id,
                origin_tab_id,
            },
        );

        assert_eq!(state.resource.title, "Loaded issue");
        assert_eq!(state.resource.state, "OPEN");
        assert_eq!(state.active_tab, Tab::Overview);
        assert!(state.status_message.is_none());
        assert!(state.loading.is_none());
    }

    #[test]
    fn open_tab_fetch_outcome_appends_and_activates_resource_tab() {
        let mut state = AppState::new(issue_resource(1, "Initial issue"));
        let second = issue_resource(2, "Second issue");
        let action = FetchAction::OpenTab {
            id: second.id.clone(),
        };
        let (request_id, origin_tab_id) = begin_test_fetch(&mut state, &action);

        apply_fetch_outcome(
            &mut state,
            FetchOutcome {
                action,
                result: Ok(second),
                refreshed_at: "12:34:56 UTC".into(),
                request_id,
                origin_tab_id,
            },
        );

        assert_eq!(state.resource.id.number, 2);
        assert_eq!(state.resource_tabs.len(), 2);
        assert_eq!(state.active_resource_tab, 1);
        assert!(state.loading.is_none());
    }

    #[test]
    fn refresh_fetch_outcome_updates_origin_tab_after_user_switches_tabs() {
        let mut state = AppState::new(issue_resource(1, "Initial issue"));
        let second = issue_resource(2, "Second issue");
        state.open_resource_in_tab(second);
        state.switch_resource_tab(0);

        let mut refreshed = issue_resource(1, "Updated issue");
        refreshed.body = "Updated body".into();
        let action = FetchAction::Refresh {
            id: refreshed.id.clone(),
        };
        let (request_id, origin_tab_id) = begin_test_fetch(&mut state, &action);
        state.switch_resource_tab(1);

        apply_fetch_outcome(
            &mut state,
            FetchOutcome {
                action,
                result: Ok(refreshed),
                refreshed_at: "12:34:56 UTC".into(),
                request_id,
                origin_tab_id,
            },
        );

        assert_eq!(state.resource.id.number, 2);
        assert_eq!(state.resource.title, "Second issue");
        assert!(state.loading.is_none());

        state.switch_resource_tab(0);

        assert_eq!(state.resource.id.number, 1);
        assert_eq!(state.resource.title, "Updated issue");
        assert_eq!(state.resource.body, "Updated body");
        assert_eq!(state.last_refreshed_at.as_deref(), Some("12:34:56 UTC"));
    }

    #[tokio::test]
    async fn initial_live_fetch_starts_before_first_draw() {
        let id = ResourceId {
            owner: "owner".into(),
            repo: "repo".into(),
            number: 1,
            kind_hint: None,
        };
        let loaded = issue_resource(1, "Loaded issue");
        let mut state = AppState::new(loading_resource_placeholder(id.clone()));
        state.set_tab(Tab::Activity);
        let (fetch_tx, mut fetch_rx) = tokio::sync::mpsc::unbounded_channel();

        let started = start_background_fetch(
            &mut state,
            FetchAction::Initial { id },
            FetchSource::OfflineFixtures(OfflineFixtureSource::new([loaded])),
            &fetch_tx,
        );

        assert!(started);
        assert_eq!(
            state.loading_message(),
            Some("opening owner/repo#1 from GitHub")
        );
        assert_eq!(state.resource.title, "Loading owner/repo#1");

        let outcome = fetch_rx.recv().await.expect("initial fetch outcome");
        apply_fetch_outcome(&mut state, outcome);

        assert_eq!(state.resource.title, "Loaded issue");
        assert_eq!(state.active_tab, Tab::Activity);
        assert!(state.loading.is_none());
    }

    #[test]
    fn duplicate_fetch_start_reports_existing_loading_without_queueing() {
        let initial = issue_resource(1, "Initial issue");
        let mut state = AppState::new(initial);
        let (fetch_tx, mut fetch_rx) = tokio::sync::mpsc::unbounded_channel();
        state.begin_loading(
            state.resource.id.clone(),
            "opening owner/repo#2 from GitHub",
        );
        let id = state.resource.id.clone();

        let started = start_background_fetch(
            &mut state,
            FetchAction::Refresh { id },
            FetchSource::Github(GithubApiGateway::new(crate::github::api::ApiDepth::Partial)),
            &fetch_tx,
        );

        assert!(!started);
        assert_eq!(
            state.status_message.as_deref(),
            Some("still loading: opening owner/repo#2 from GitHub")
        );
        assert!(fetch_rx.try_recv().is_err());
    }

    #[test]
    fn completed_fetch_clears_blocked_loading_status_on_other_tabs() {
        let mut state = AppState::new(issue_resource(1, "Initial issue"));
        state.open_resource_in_tab(issue_resource(2, "Second issue"));
        state.switch_resource_tab(0);
        let action = FetchAction::Refresh {
            id: state.resource.id.clone(),
        };
        let (request_id, origin_tab_id) = begin_test_fetch(&mut state, &action);
        state.switch_resource_tab(1);
        let (fetch_tx, mut fetch_rx) = tokio::sync::mpsc::unbounded_channel();
        let blocked_id = state.resource.id.clone();

        let started = start_background_fetch(
            &mut state,
            FetchAction::Refresh { id: blocked_id },
            FetchSource::Github(GithubApiGateway::new(crate::github::api::ApiDepth::Partial)),
            &fetch_tx,
        );

        assert!(!started);
        assert_eq!(
            state.status_message.as_deref(),
            Some("still loading: refreshing owner/repo#1 from GitHub")
        );
        assert!(fetch_rx.try_recv().is_err());

        apply_fetch_outcome(
            &mut state,
            FetchOutcome {
                action,
                result: Ok(issue_resource(1, "Updated issue")),
                refreshed_at: "12:34:56 UTC".into(),
                request_id,
                origin_tab_id,
            },
        );

        assert_eq!(state.resource.id.number, 2);
        assert!(state.status_message.is_none());
        state.switch_resource_tab(0);
        assert_eq!(state.resource.title, "Updated issue");
        assert!(state.status_message.is_none());
    }

    #[test]
    fn offline_fixture_source_fetches_by_canonical_name_without_kind_hint() {
        let fixture = issue_resource(2, "Linked issue");
        let source = OfflineFixtureSource::new([fixture.clone()]);
        let id_without_kind = ResourceId {
            owner: "owner".into(),
            repo: "repo".into(),
            number: 2,
            kind_hint: None,
        };

        let loaded = source
            .fetch_resource(&id_without_kind)
            .expect("fixture resource");

        assert_eq!(loaded.id, fixture.id);
        assert_eq!(loaded.title, "Linked issue");
    }

    #[test]
    fn offline_fixture_source_reports_missing_navigation_target() {
        let source = OfflineFixtureSource::new([issue_resource(1, "Initial issue")]);
        let missing = ResourceId {
            owner: "owner".into(),
            repo: "repo".into(),
            number: 2,
            kind_hint: Some(ResourceKind::Issue),
        };

        let error = source
            .fetch_resource(&missing)
            .expect_err("missing fixture should fail");

        assert_eq!(
            error.to_string(),
            "offline fixture mode: no fixture loaded for owner/repo#2"
        );
    }

    #[test]
    fn failed_back_fetch_restores_history_target() {
        let initial = issue_resource(1, "Initial issue");
        let previous = issue_resource(2, "Previous issue");
        let mut state = AppState::new(initial);
        state.history.push(previous.id.clone());
        let popped = state.pop_history().unwrap();
        let action = FetchAction::Back { to: popped };
        let (request_id, origin_tab_id) = begin_test_fetch(&mut state, &action);

        apply_fetch_outcome(
            &mut state,
            FetchOutcome {
                action,
                result: Err(anyhow::anyhow!("network down")),
                refreshed_at: "12:34:56 UTC".into(),
                request_id,
                origin_tab_id,
            },
        );

        assert_eq!(state.history, [previous.id]);
        assert_eq!(state.last_error.as_deref(), Some("network down"));
        assert!(state.loading.is_none());
    }
}
