using System.Text;

namespace Sunfish.Config;

/// <summary>
/// Fichero INI que conserva comentarios, lineas en blanco y orden al reescribirse.
/// Comentarios: lineas que empiezan por '#' o ';'. Claves y secciones sin distinguir mayusculas.
/// </summary>
internal sealed class IniDocument
{
    private readonly List<IniSection> _sections = new();

    public IniDocument() => _sections.Add(new IniSection(null, ""));

    /// <summary>Lineas que no se pudieron interpretar (numero de linea, texto).</summary>
    public List<(int Line, string Text)> Errors { get; } = new();

    /// <summary>Lo que hay antes de la primera seccion.</summary>
    public IniSection Preamble => _sections[0];

    public IEnumerable<IniSection> Sections => _sections.Skip(1);

    public static IniDocument Parse(string text)
    {
        var doc = new IniDocument();
        IniSection current = doc._sections[0];
        int number = 0;
        foreach (var rawLine in text.Split('\n'))
        {
            number++;
            string line = rawLine.TrimEnd('\r');
            string trimmed = line.Trim();
            if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == ';')
            {
                current.Lines.Add(new IniLine(line, null, null, number));
            }
            else if (trimmed[0] == '[' && trimmed[^1] == ']')
            {
                current = new IniSection(line, trimmed[1..^1].Trim()) { LineNumber = number };
                doc._sections.Add(current);
            }
            else
            {
                int eq = line.IndexOf('=');
                if (eq <= 0)
                {
                    doc.Errors.Add((number, line));
                    current.Lines.Add(new IniLine(line, null, null, number));
                }
                else
                {
                    current.Lines.Add(new IniLine(line, line[..eq].Trim(), line[(eq + 1)..].Trim(), number));
                }
            }
        }
        // Split deja una linea vacia final si el texto acaba en salto de linea: no se duplica al guardar.
        var last = doc._sections[^1].Lines;
        if (last.Count > 0 && last[^1].Raw.Length == 0 && text.EndsWith('\n'))
            last.RemoveAt(last.Count - 1);
        return doc;
    }

    public IniSection? Find(string name) =>
        Sections.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));

    public IniSection GetOrAdd(string name)
    {
        var section = Find(name);
        if (section is not null)
            return section;
        // Separa la seccion nueva de la anterior con una linea en blanco.
        var previous = _sections[^1];
        if (_sections.Count > 1 || previous.Lines.Count > 0)
        {
            if (previous.Lines.Count == 0 || previous.Lines[^1].Raw.Trim().Length > 0)
                previous.Lines.Add(new IniLine("", null, null, 0));
        }
        section = new IniSection($"[{name}]", name);
        _sections.Add(section);
        return section;
    }

    public void Remove(IniSection section)
    {
        if (section != _sections[0])
            _sections.Remove(section);
    }

    public string ToText(string newLine)
    {
        var sb = new StringBuilder();
        foreach (var section in _sections)
        {
            if (section.Header is not null)
                sb.Append(section.Header).Append(newLine);
            foreach (var line in section.Lines)
                sb.Append(line.Raw).Append(newLine);
        }
        return sb.ToString();
    }
}

internal sealed class IniSection
{
    public IniSection(string? header, string name)
    {
        Header = header;
        Name = name;
    }

    /// <summary>Linea original de la cabecera; null en el preambulo.</summary>
    public string? Header { get; }

    /// <summary>Texto entre corchetes, p. ej. "Share fotos".</summary>
    public string Name { get; }

    public int LineNumber { get; init; }

    public List<IniLine> Lines { get; } = new();

    /// <summary>Primera palabra del nombre: "Share" en "[Share fotos]".</summary>
    public string Kind
    {
        get
        {
            int sp = Name.IndexOf(' ');
            return sp < 0 ? Name : Name[..sp];
        }
    }

    /// <summary>Resto del nombre: "fotos" en "[Share fotos]".</summary>
    public string Title
    {
        get
        {
            int sp = Name.IndexOf(' ');
            return sp < 0 ? "" : Name[(sp + 1)..].Trim();
        }
    }

    public IEnumerable<IniLine> Entries => Lines.Where(l => l.Key is not null);

    public IniLine? FindEntry(string key) =>
        Entries.FirstOrDefault(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase));

    public string? Get(string key) => FindEntry(key)?.Value;

    public bool Has(string key) => FindEntry(key) is not null;

    /// <summary>Cambia el valor conservando la posicion de la clave; si no existe, la anade al final.</summary>
    public void Set(string key, string value)
    {
        int index = Lines.FindIndex(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase));
        if (index >= 0)
        {
            var old = Lines[index];
            Lines[index] = new IniLine($"{old.Key} = {value}", old.Key, value, old.Number);
            // Una clave repetida se queda solo con su primera aparicion.
            for (int i = Lines.Count - 1; i > index; i--)
                if (string.Equals(Lines[i].Key, key, StringComparison.OrdinalIgnoreCase))
                    Lines.RemoveAt(i);
            return;
        }
        // Detras de la ultima clave, para no quedar despues de los blancos que separan secciones.
        int insert = Lines.FindLastIndex(l => l.Key is not null) + 1;
        Lines.Insert(insert, new IniLine($"{key} = {value}", key, value, 0));
    }

    public void Remove(string key) =>
        Lines.RemoveAll(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase));
}

internal sealed record IniLine(string Raw, string? Key, string? Value, int Number);
