// Marker helpers are reused by bootstrap flows, CLI commands, and tests, so a
// small amount of dead-code noise is expected across build combinations.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

pub const MARKER_FILE: &str = ".ccd.toml";
const MARKER_SCHEMA_VERSION_V2: u32 = 2;

#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MarkerSubstrate {
    #[default]
    Git,
    Directory,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawRepoMarker {
    #[serde(default = "default_version")]
    version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    project_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    substrate: Option<MarkerSubstrate>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    display_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    hosts: Option<HostsConfig>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct LegacyRawRepoMarker {
    #[serde(default = "default_version")]
    version: u32,
    #[serde(default)]
    repo_id: Option<String>,
    #[serde(default)]
    project_id: Option<String>,
    #[serde(default)]
    substrate: Option<MarkerSubstrate>,
    #[serde(default)]
    display_name: Option<String>,
    #[serde(default)]
    hosts: Option<HostsConfig>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum MarkerRepairKind {
    LegacyRepoIdV1,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct MarkerRepairPreview {
    pub kind: MarkerRepairKind,
    pub marker_path: String,
    pub before_version: u32,
    pub after_version: u32,
    pub preserved_project_id: String,
    pub preserved_display_name: Option<String>,
    pub preserved_substrate: MarkerSubstrate,
    pub preserved_hosts_applied: Option<Vec<String>>,
    pub before_field: &'static str,
    pub after_field: &'static str,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct MarkerRepairOutcome {
    pub preview: MarkerRepairPreview,
    pub repaired_marker: String,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct HostsConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub applied: Option<Vec<String>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoMarker {
    pub version: u32,
    pub locality_id: String,
    pub display_name: Option<String>,
    pub hosts_applied: Option<Vec<String>>,
    substrate: MarkerSubstrate,
    include_substrate: bool,
}

impl RepoMarker {
    pub fn new(locality_id: impl Into<String>, display_name: Option<String>) -> Result<Self> {
        Self::new_internal(
            MARKER_SCHEMA_VERSION_V2,
            locality_id.into(),
            display_name,
            None,
            MarkerSubstrate::Git,
            false,
        )
    }

    pub(crate) fn new_directory(
        locality_id: impl Into<String>,
        display_name: Option<String>,
    ) -> Result<Self> {
        Self::new_internal(
            MARKER_SCHEMA_VERSION_V2,
            locality_id.into(),
            display_name,
            None,
            MarkerSubstrate::Directory,
            true,
        )
    }

    pub(crate) fn rewrite_with_locality_id(
        &self,
        locality_id: impl Into<String>,
        display_name: Option<String>,
    ) -> Result<Self> {
        Self::new_internal(
            self.version,
            locality_id.into(),
            display_name,
            self.hosts_applied.clone(),
            self.substrate,
            self.include_substrate,
        )
    }

    pub fn with_hosts_applied(&self, hosts_applied: Option<Vec<String>>) -> Result<Self> {
        Self::new_internal(
            self.version,
            self.locality_id.clone(),
            self.display_name.clone(),
            hosts_applied,
            self.substrate,
            self.include_substrate,
        )
    }

    pub(crate) fn substrate(&self) -> MarkerSubstrate {
        self.substrate
    }

    pub fn from_toml(contents: &str) -> Result<Self> {
        let raw: RawRepoMarker = toml::from_str(contents).context("failed to parse marker TOML")?;
        Self::from_raw(raw)
    }

    fn from_legacy_v1(raw: LegacyRawRepoMarker) -> Result<Self> {
        if raw.version != 1 {
            bail!("legacy repair only supports version 1 markers with `repo_id`");
        }
        let repo_id = raw
            .repo_id
            .ok_or_else(|| anyhow::anyhow!("legacy version 1 marker does not contain `repo_id`"))?;
        if let Some(project_id) = raw.project_id {
            if project_id != repo_id {
                bail!(
                    "legacy marker has conflicting `repo_id` and `project_id`; refusing automatic repair"
                );
            }
        }
        let substrate = raw.substrate.unwrap_or_default();
        let include_substrate = raw.substrate.is_some();
        Self::new_internal(
            MARKER_SCHEMA_VERSION_V2,
            repo_id,
            raw.display_name,
            raw.hosts.and_then(|h| h.applied),
            substrate,
            include_substrate,
        )
    }

    pub fn to_toml(&self) -> Result<String> {
        self.validate()?;
        toml::to_string(&self.to_raw()).context("failed to serialize marker TOML")
    }

    fn validate(&self) -> Result<()> {
        if self.version != MARKER_SCHEMA_VERSION_V2 {
            bail!(
                "unsupported marker schema version `{}`; only version 2 is supported — re-run `ccd link --path .` to upgrade",
                self.version
            );
        }

        if self.substrate != MarkerSubstrate::Git && !self.include_substrate {
            bail!("version 2 markers must serialize non-git substrates explicitly");
        }

        validate_locality_id(&self.locality_id)?;

        if let Some(display_name) = &self.display_name {
            if display_name.is_empty() {
                bail!("display_name cannot be empty");
            }
        }

        Ok(())
    }

    fn new_internal(
        version: u32,
        locality_id: String,
        display_name: Option<String>,
        hosts_applied: Option<Vec<String>>,
        substrate: MarkerSubstrate,
        include_substrate: bool,
    ) -> Result<Self> {
        let marker = Self {
            version,
            locality_id,
            display_name,
            hosts_applied,
            substrate,
            include_substrate,
        };
        marker.validate()?;
        Ok(marker)
    }

    fn from_raw(raw: RawRepoMarker) -> Result<Self> {
        if raw.version != MARKER_SCHEMA_VERSION_V2 {
            bail!(
                "version {} markers are no longer supported; re-run `ccd link --path .` to upgrade to version 2",
                raw.version
            );
        }

        let locality_id = raw
            .project_id
            .ok_or_else(|| anyhow::anyhow!("version 2 markers require `project_id`"))?;
        let include_substrate = raw.substrate.is_some();
        let hosts_applied = raw.hosts.and_then(|h| h.applied);
        Self::new_internal(
            MARKER_SCHEMA_VERSION_V2,
            locality_id,
            raw.display_name,
            hosts_applied,
            raw.substrate.unwrap_or_default(),
            include_substrate,
        )
    }

    fn to_raw(&self) -> RawRepoMarker {
        RawRepoMarker {
            version: self.version,
            project_id: Some(self.locality_id.clone()),
            substrate: self.include_substrate.then_some(self.substrate),
            display_name: self.display_name.clone(),
            hosts: self.hosts_applied.as_ref().map(|applied| HostsConfig {
                applied: Some(applied.clone()),
            }),
        }
    }
}

pub fn load(repo_root: &Path) -> Result<Option<RepoMarker>> {
    let path = repo_root.join(MARKER_FILE);
    match fs::read_to_string(&path) {
        Ok(contents) => Ok(Some(RepoMarker::from_toml(&contents)?)),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
    }
}

pub fn write(repo_root: &Path, marker: &RepoMarker) -> Result<PathBuf> {
    let path = repo_root.join(MARKER_FILE);
    let contents = marker.to_toml()?;
    fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(path)
}

pub(crate) fn legacy_repair_preview(repo_root: &Path) -> Result<Option<MarkerRepairPreview>> {
    let path = repo_root.join(MARKER_FILE);
    let contents = match fs::read_to_string(&path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(error).with_context(|| format!("failed to read {}", path.display()))
        }
    };
    let raw: LegacyRawRepoMarker = match toml::from_str(&contents) {
        Ok(raw) => raw,
        Err(error) => {
            return Err(error).context("failed to parse marker TOML for legacy repair probe")
        }
    };
    if raw.version != 1 || raw.repo_id.is_none() {
        return Ok(None);
    }
    let marker = RepoMarker::from_legacy_v1(raw)?;
    Ok(Some(MarkerRepairPreview {
        kind: MarkerRepairKind::LegacyRepoIdV1,
        marker_path: path.display().to_string(),
        before_version: 1,
        after_version: MARKER_SCHEMA_VERSION_V2,
        preserved_project_id: marker.locality_id,
        preserved_display_name: marker.display_name,
        preserved_substrate: marker.substrate,
        preserved_hosts_applied: marker.hosts_applied,
        before_field: "repo_id",
        after_field: "project_id",
    }))
}

pub(crate) fn repair_legacy_marker(repo_root: &Path) -> Result<Option<MarkerRepairOutcome>> {
    let preview = match legacy_repair_preview(repo_root)? {
        Some(preview) => preview,
        None => return Ok(None),
    };
    let marker = RepoMarker::new_internal(
        MARKER_SCHEMA_VERSION_V2,
        preview.preserved_project_id.clone(),
        preview.preserved_display_name.clone(),
        preview.preserved_hosts_applied.clone(),
        preview.preserved_substrate,
        preview.preserved_substrate != MarkerSubstrate::Git,
    )?;
    let repaired_marker = marker.to_toml()?;
    fs::write(repo_root.join(MARKER_FILE), &repaired_marker)
        .with_context(|| format!("failed to write {}", repo_root.join(MARKER_FILE).display()))?;
    Ok(Some(MarkerRepairOutcome {
        preview,
        repaired_marker,
    }))
}

fn default_version() -> u32 {
    MARKER_SCHEMA_VERSION_V2
}

pub(crate) fn validate_locality_id(locality_id: &str) -> Result<&str> {
    if locality_id.is_empty() {
        bail!("project_id cannot be empty");
    }

    if locality_id == "." || locality_id == ".." {
        bail!("project_id cannot be `.` or `..`");
    }

    if locality_id.contains('/') || locality_id.contains('\\') {
        bail!("project_id cannot contain path separators");
    }

    if locality_id.as_bytes().contains(&0) {
        bail!("project_id cannot contain NUL bytes");
    }

    Ok(locality_id)
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::*;

    #[test]
    fn parses_v2_marker_with_project_id() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
"#,
        )
        .expect("marker parses");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
        assert_eq!(marker.display_name, None);
        assert_eq!(
            marker.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_123\"\n"
        );
    }

    #[test]
    fn parses_v2_marker_with_project_id_and_substrate() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "git"
"#,
        )
        .expect("marker parses");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
        assert_eq!(
            marker.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_123\"\nsubstrate = \"git\"\n"
        );
    }

    #[test]
    fn parses_v2_marker_with_directory_substrate() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "directory"
"#,
        )
        .expect("marker parses");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
        assert_eq!(
            marker.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_123\"\nsubstrate = \"directory\"\n"
        );
    }

    #[test]
    fn parses_marker_without_explicit_version() {
        let marker = RepoMarker::from_toml(
            r#"
project_id = "ccdrepo_123"
"#,
        )
        .expect("marker parses — defaults to v2");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
    }

    #[test]
    fn rejects_v1_markers() {
        let error = RepoMarker::from_toml(
            r#"
version = 1
project_id = "ccdrepo_123"
"#,
        )
        .expect_err("v1 should be rejected");

        assert!(error.to_string().contains("no longer supported"));
    }

    #[test]
    fn rejects_marker_with_no_project_id() {
        let error = RepoMarker::from_toml(
            r#"
version = 2
display_name = "ccd-guide"
"#,
        )
        .expect_err("missing project_id should fail");

        assert!(error.to_string().contains("require"));
    }

    #[test]
    fn rejects_legacy_repo_id_field() {
        let error = RepoMarker::from_toml(
            r#"
version = 2
repo_id = "ccdrepo_123"
"#,
        )
        .expect_err("repo_id field should be rejected as unknown");

        let message = format!("{error:#}");
        assert!(message.contains("repo_id"));
    }

    #[test]
    fn rejects_legacy_marker_fields() {
        let error = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
branch_on_session = false
"#,
        )
        .expect_err("legacy fields should fail");

        let message = format!("{error:#}");
        assert!(message.contains("branch_on_session"));
    }

    #[test]
    fn writes_and_loads_marker_file() {
        let temp = tempdir().expect("tempdir");
        let marker = RepoMarker::new("ccdrepo_123", Some("ccd-guide".to_owned())).expect("marker");

        let path = write(temp.path(), &marker).expect("marker written");
        let reloaded = load(temp.path()).expect("marker loaded");

        assert_eq!(path, temp.path().join(MARKER_FILE));
        assert_eq!(reloaded, Some(marker));
    }

    #[test]
    fn writes_and_loads_v2_directory_marker_file() {
        let temp = tempdir().expect("tempdir");
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "directory"
display_name = "ccd-guide"
"#,
        )
        .expect("marker");

        let path = write(temp.path(), &marker).expect("marker written");
        let reloaded = load(temp.path()).expect("marker loaded");

        assert_eq!(path, temp.path().join(MARKER_FILE));
        assert_eq!(reloaded, Some(marker));
    }

    #[test]
    fn rewrite_preserves_project_id_encoding() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "git"
"#,
        )
        .expect("marker parses");

        let updated = marker
            .rewrite_with_locality_id("ccdrepo_456", Some("ccd-guide".to_owned()))
            .expect("marker rewrites");

        assert_eq!(
            updated.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_456\"\nsubstrate = \"git\"\ndisplay_name = \"ccd-guide\"\n"
        );
    }

    #[test]
    fn rejects_invalid_project_ids() {
        let error = RepoMarker::new("../bad", None).expect_err("project_id should fail");

        assert!(error.to_string().contains("project_id"));
    }

    #[test]
    fn round_trips_hosts_applied_list() {
        let toml_input = r#"version = 2
project_id = "ccdrepo_demo"

[hosts]
applied = ["claude", "codex"]
"#;

        let parsed = RepoMarker::from_toml(toml_input).expect("parse");
        assert_eq!(
            parsed.hosts_applied.as_deref(),
            Some(&["claude".to_owned(), "codex".to_owned()][..])
        );

        let rendered = parsed.to_toml().expect("serialize");
        // Parse-render-parse round-trip must be stable on the hosts list.
        let reparsed = RepoMarker::from_toml(&rendered).expect("reparse");
        assert_eq!(reparsed.hosts_applied, parsed.hosts_applied);
    }

    #[test]
    fn omits_hosts_section_when_applied_is_none() {
        let marker = RepoMarker::new("ccdrepo_demo", None).expect("marker");
        assert!(marker.hosts_applied.is_none());
        let toml = marker.to_toml().expect("serialize");
        // Absent field → no [hosts] section — keeps byte compatibility with pre-PR-2 files.
        assert!(
            !toml.contains("[hosts]"),
            "expected no [hosts] section, got:\n{toml}"
        );
    }

    #[test]
    fn previews_legacy_repo_id_v1_repair_without_data_loss() {
        let temp = tempdir().expect("tempdir");
        fs::write(
            temp.path().join(MARKER_FILE),
            r#"version = 1
repo_id = "ccdrepo_legacy"
substrate = "directory"
display_name = "legacy workspace"

[hosts]
applied = ["claude", "codex"]
"#,
        )
        .expect("write legacy marker");

        let preview = legacy_repair_preview(temp.path())
            .expect("repair preview succeeds")
            .expect("repair is available");

        assert_eq!(preview.before_version, 1);
        assert_eq!(preview.after_version, 2);
        assert_eq!(preview.preserved_project_id, "ccdrepo_legacy");
        assert_eq!(
            preview.preserved_display_name.as_deref(),
            Some("legacy workspace")
        );
        assert_eq!(preview.preserved_substrate, MarkerSubstrate::Directory);
        assert_eq!(
            preview.preserved_hosts_applied.as_deref(),
            Some(&["claude".to_owned(), "codex".to_owned()][..])
        );
    }

    #[test]
    fn repairs_legacy_repo_id_v1_marker_preserving_identity() {
        let temp = tempdir().expect("tempdir");
        fs::write(
            temp.path().join(MARKER_FILE),
            r#"version = 1
repo_id = "ccdrepo_legacy"
display_name = "legacy workspace"
"#,
        )
        .expect("write legacy marker");

        let outcome = repair_legacy_marker(temp.path())
            .expect("repair succeeds")
            .expect("repair performed");
        let repaired = fs::read_to_string(temp.path().join(MARKER_FILE)).expect("read repaired");

        assert_eq!(outcome.preview.preserved_project_id, "ccdrepo_legacy");
        assert!(repaired.contains("version = 2"));
        assert!(repaired.contains("project_id = \"ccdrepo_legacy\""));
        assert!(!repaired.contains("repo_id"));
        let marker = load(temp.path())
            .expect("load repaired marker")
            .expect("marker");
        assert_eq!(marker.locality_id, "ccdrepo_legacy");
        assert_eq!(marker.display_name.as_deref(), Some("legacy workspace"));
    }
}
